From 3681fd132b91fe383f079ca82f4ce76d1036e120 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Jun 2026 18:34:24 +0800 Subject: [PATCH] Open workspace files in desktop targets --- packages/app/e2e/helpers/desktop-updates.ts | 51 +- .../app/e2e/workspace-open-in-editor.spec.ts | 36 +- .../src/components/icons/editor-app-icons.tsx | 17 +- packages/app/src/desktop/host.ts | 19 + packages/app/src/git/github-url.test.ts | 81 +++- packages/app/src/git/github-url.ts | 47 ++ .../src/hooks/use-preferred-editor.test.ts | 6 + .../app/src/hooks/use-preferred-editor.ts | 5 +- .../workspace-open-in-editor-button.tsx | 146 +++--- .../workspace/workspace-open-targets.test.ts | 36 -- .../workspace/workspace-open-targets.ts | 13 - .../screens/workspace/workspace-screen.tsx | 45 +- .../app/src/workspace/desktop-open-targets.ts | 71 +++ .../app/src/workspace/editor-targets.test.ts | 11 + packages/app/src/workspace/editor-targets.ts | 15 + .../app/src/workspace/file-open/index.test.ts | 91 ++++ packages/app/src/workspace/file-open/index.ts | 102 ++++ .../src/workspace/open-target-planner.test.ts | 150 ++++++ .../app/src/workspace/open-target-planner.ts | 146 ++++++ packages/client/src/daemon-client.ts | 34 -- .../src/features/editor-targets.test.ts | 459 ++++++++++++++++++ .../desktop/src/features/editor-targets.ts | 353 ++++++++++++++ packages/desktop/src/main.ts | 2 + packages/desktop/src/preload.ts | 9 + packages/protocol/src/messages.ts | 80 ++- .../protocol/src/messages.workspaces.test.ts | 60 +-- .../server/src/server/editor-targets.test.ts | 118 ----- packages/server/src/server/editor-targets.ts | 168 ------- packages/server/src/server/session.ts | 128 +---- .../src/server/session.workspaces.test.ts | 153 +----- 30 files changed, 1830 insertions(+), 822 deletions(-) delete mode 100644 packages/app/src/screens/workspace/workspace-open-targets.test.ts delete mode 100644 packages/app/src/screens/workspace/workspace-open-targets.ts create mode 100644 packages/app/src/workspace/desktop-open-targets.ts create mode 100644 packages/app/src/workspace/editor-targets.test.ts create mode 100644 packages/app/src/workspace/editor-targets.ts create mode 100644 packages/app/src/workspace/open-target-planner.test.ts create mode 100644 packages/app/src/workspace/open-target-planner.ts create mode 100644 packages/desktop/src/features/editor-targets.test.ts create mode 100644 packages/desktop/src/features/editor-targets.ts delete mode 100644 packages/server/src/server/editor-targets.test.ts delete mode 100644 packages/server/src/server/editor-targets.ts diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/app/e2e/helpers/desktop-updates.ts index 9fac2a086..c6c728fca 100644 --- a/packages/app/e2e/helpers/desktop-updates.ts +++ b/packages/app/e2e/helpers/desktop-updates.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { appendFile } from "node:fs/promises"; import { expect, type Page } from "@playwright/test"; import { openSettings } from "./app"; import { getE2EDaemonPort } from "./daemon-port"; @@ -64,6 +65,21 @@ export interface DesktopBridgeConfig { * false so tests that only assert copy don't inadvertently trigger state changes. */ confirmShouldAccept?: boolean; + editorTargets?: DesktopEditorTargetConfig[]; + editorRecordPath?: string; +} + +interface DesktopEditorTargetConfig { + id: string; + label: string; + kind: "editor" | "file-manager"; +} + +interface DesktopEditorOpenRecord { + editorId: string; + path: string; + cwd?: string; + mode?: "open" | "reveal"; } export interface ConfirmDialogCall { @@ -74,6 +90,7 @@ export interface ConfirmDialogCall { declare global { interface Window { __capturedDialogCall: ConfirmDialogCall | undefined; + __recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise; } } @@ -87,6 +104,15 @@ declare global { * can assert dialog copy without depending on window.confirm concatenation. */ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise { + if (config.editorRecordPath) { + await page.exposeFunction( + "__recordDesktopEditorOpen", + async (input: DesktopEditorOpenRecord) => { + await appendFile(config.editorRecordPath as string, `${JSON.stringify(input)}\n`, "utf8"); + }, + ); + } + await page.addInitScript((cfg) => { // Mutable state shared across IPC calls within this page let manageDaemon = cfg.manageBuiltInDaemon ?? false; @@ -108,7 +134,19 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi }; } - (window as unknown as { paseoDesktop: unknown }).paseoDesktop = { + const desktopBridge: { + platform: string; + invoke: (command: string, args?: Record) => Promise; + dialog: { + ask: (message: string, options?: Record) => Promise; + }; + getPendingOpenProject: () => Promise; + events: { on: () => Promise<() => void> }; + editor?: { + listTargets: () => Promise; + openTarget: (input: DesktopEditorOpenRecord) => Promise; + }; + } = { platform: "darwin", invoke: async (command: string, args?: Record) => { if (command === "check_app_update") { @@ -202,6 +240,17 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi getPendingOpenProject: async () => null, events: { on: async () => () => undefined }, }; + + if (cfg.editorTargets) { + desktopBridge.editor = { + listTargets: async () => cfg.editorTargets ?? [], + openTarget: async (input: DesktopEditorOpenRecord) => { + await window.__recordDesktopEditorOpen?.(input); + }, + }; + } + + (window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge; }, config); } diff --git a/packages/app/e2e/workspace-open-in-editor.spec.ts b/packages/app/e2e/workspace-open-in-editor.spec.ts index 3d37629a3..35d32a1d9 100644 --- a/packages/app/e2e/workspace-open-in-editor.spec.ts +++ b/packages/app/e2e/workspace-open-in-editor.spec.ts @@ -5,8 +5,10 @@ import { injectDesktopBridge } from "./helpers/desktop-updates"; import { clickSettingsBackToWorkspace } from "./helpers/settings"; interface EditorOpenRecord { - command: string; - args: string[]; + editorId: string; + path: string; + cwd?: string; + mode?: "open" | "reveal"; } function requireE2EEnv(name: string): string { @@ -43,8 +45,8 @@ async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise async function expectEditorOpened(input: { recordPath: string; - command: string; - workspacePath: string; + editorId: string; + path: string; afterCount: number; }): Promise { await expect @@ -53,10 +55,7 @@ async function expectEditorOpened(input: { const records = await readEditorOpenRecords(input.recordPath); return records .slice(input.afterCount) - .some( - (record) => - record.command === input.command && record.args.includes(input.workspacePath), - ); + .some((record) => record.editorId === input.editorId && record.path === input.path); }, { timeout: 30_000 }, ) @@ -73,7 +72,14 @@ test.describe("Workspace open in editor", () => { const serverId = requireE2EEnv("E2E_SERVER_ID"); const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH"); await rm(recordPath, { force: true }); - await injectDesktopBridge(page, { serverId }); + await injectDesktopBridge(page, { + serverId, + editorTargets: [ + { id: "cursor", label: "Cursor", kind: "editor" }, + { id: "vscode", label: "VS Code", kind: "editor" }, + ], + editorRecordPath: recordPath, + }); const workspace = await withWorkspace({ prefix: "workspace-editor-target-" }); await workspace.navigateTo(); @@ -81,8 +87,8 @@ test.describe("Workspace open in editor", () => { await chooseEditorTarget(page, "vscode"); await expectEditorOpened({ recordPath, - command: "code", - workspacePath: workspace.repoPath, + editorId: "vscode", + path: workspace.repoPath, afterCount: 0, }); const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length; @@ -94,8 +100,8 @@ test.describe("Workspace open in editor", () => { await page.getByTestId("workspace-open-in-editor-primary").click(); await expectEditorOpened({ recordPath, - command: "code", - workspacePath: workspace.repoPath, + editorId: "vscode", + path: workspace.repoPath, afterCount: recordsAfterSelection, }); const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length; @@ -105,8 +111,8 @@ test.describe("Workspace open in editor", () => { await page.getByTestId("workspace-open-in-editor-primary").click(); await expectEditorOpened({ recordPath, - command: "code", - workspacePath: workspace.repoPath, + editorId: "vscode", + path: workspace.repoPath, afterCount: recordsAfterReturnOpen, }); }); diff --git a/packages/app/src/components/icons/editor-app-icons.tsx b/packages/app/src/components/icons/editor-app-icons.tsx index 2abdba0ed..bb8ec12dc 100644 --- a/packages/app/src/components/icons/editor-app-icons.tsx +++ b/packages/app/src/components/icons/editor-app-icons.tsx @@ -1,11 +1,7 @@ import { SquareTerminal } from "lucide-react-native"; import { useMemo } from "react"; import { Image, type ImageSourcePropType } from "react-native"; -import { - isKnownEditorTargetId, - type EditorTargetId, - type KnownEditorTargetId, -} from "@getpaseo/protocol/messages"; +import { isKnownEditorTargetId, type EditorTargetId } from "@/workspace/editor-targets"; interface EditorAppIconProps { editorId: EditorTargetId; @@ -14,7 +10,7 @@ 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"), @@ -25,15 +21,16 @@ 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 hasBundledEditorAppIcon(editorId: EditorTargetId): boolean { + return isKnownEditorTargetId(editorId) && EDITOR_APP_IMAGES[editorId] !== undefined; } export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) { const imageStyle = useMemo(() => ({ width: size, height: size }), [size]); - if (!hasBundledEditorAppIcon(editorId)) { + const source = EDITOR_APP_IMAGES[editorId]; + if (!source) { return ; } - return ; + return ; } diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index a566d2373..ade806290 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -51,6 +51,24 @@ export interface DesktopOpenerBridge { openUrl?: (url: string) => Promise; } +export interface DesktopEditorTargetDescriptor { + id: string; + label: string; + kind: "editor" | "file-manager"; +} + +export interface DesktopEditorOpenTargetInput { + editorId: string; + path: string; + cwd?: string; + mode?: "open" | "reveal"; +} + +export interface DesktopEditorBridge { + listTargets?: () => Promise; + openTarget?: (input: DesktopEditorOpenTargetInput) => Promise; +} + export interface DesktopWebUtilsBridge { getPathForFile?: (file: File) => string; } @@ -111,6 +129,7 @@ export interface DesktopHostBridge { dialog?: DesktopDialogBridge; notification?: DesktopNotificationBridge; opener?: DesktopOpenerBridge; + editor?: DesktopEditorBridge; webUtils?: DesktopWebUtilsBridge; menu?: DesktopMenuBridge; browser?: DesktopBrowserBridge; diff --git a/packages/app/src/git/github-url.test.ts b/packages/app/src/git/github-url.test.ts index e26ab8b52..9b6329caf 100644 --- a/packages/app/src/git/github-url.test.ts +++ b/packages/app/src/git/github-url.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-url"; +import { + buildGitHubBlobUrl, + buildGitHubBranchTreeUrl, + parseGitHubRepoFromRemote, +} from "./github-url"; describe("parseGitHubRepoFromRemote", () => { it.each([ @@ -51,3 +55,78 @@ describe("buildGitHubBranchTreeUrl", () => { ).toBeNull(); }); }); + +describe("buildGitHubBlobUrl", () => { + it("builds a blob URL for a file path", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "git@github.com:acme/repo.git", + branch: "main", + path: "src/index.ts", + }), + ).toBe("https://github.com/acme/repo/blob/main/src/index.ts"); + }); + + it("appends a single-line anchor", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "src/index.ts", + lineStart: 12, + }), + ).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12"); + }); + + it("appends a line range anchor", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "src/index.ts", + lineStart: 12, + lineEnd: 20, + }), + ).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20"); + }); + + it("strips leading slashes and encodes path segments", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "/src/a b/c#d.ts", + }), + ).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts"); + }); + + it("normalizes harmless dot segments in the blob path", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "./src/../index.ts", + }), + ).toBe("https://github.com/acme/repo/blob/main/index.ts"); + }); + + it("returns null for blob paths that escape above the repo root", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "../outside.ts", + }), + ).toBeNull(); + }); + + it("returns null when the path is missing", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "", + }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/git/github-url.ts b/packages/app/src/git/github-url.ts index fc1702a5a..875da903a 100644 --- a/packages/app/src/git/github-url.ts +++ b/packages/app/src/git/github-url.ts @@ -20,3 +20,50 @@ export function buildGitHubBranchTreeUrl(input: { const encodedBranch = branch.split("/").map(encodeURIComponent).join("/"); return `https://github.com/${repo}/tree/${encodedBranch}`; } + +function normalizeGitHubBlobPath(path: string | null | undefined): string | null { + const segments: string[] = []; + const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, ""); + if (!trimmed) { + return null; + } + for (const segment of trimmed.split("/")) { + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length === 0) { + return null; + } + segments.pop(); + continue; + } + segments.push(segment); + } + return segments.join("/") || null; +} + +export function buildGitHubBlobUrl(input: { + remoteUrl: string | null | undefined; + branch: string | null | undefined; + path: string | null | undefined; + lineStart?: number; + lineEnd?: number; +}): string | null { + const repo = parseGitHubRepoFromRemote(input.remoteUrl); + const branch = input.branch?.trim(); + const filePath = normalizeGitHubBlobPath(input.path); + if (!repo || !branch || branch === "HEAD" || !filePath) { + return null; + } + const encodedBranch = branch.split("/").map(encodeURIComponent).join("/"); + const encodedPath = filePath.split("/").map(encodeURIComponent).join("/"); + let url = `https://github.com/${repo}/blob/${encodedBranch}/${encodedPath}`; + if (input.lineStart && input.lineStart > 0) { + url += `#L${input.lineStart}`; + if (input.lineEnd && input.lineEnd > input.lineStart) { + url += `-L${input.lineEnd}`; + } + } + return url; +} diff --git a/packages/app/src/hooks/use-preferred-editor.test.ts b/packages/app/src/hooks/use-preferred-editor.test.ts index 97889e38e..49a3c4c18 100644 --- a/packages/app/src/hooks/use-preferred-editor.test.ts +++ b/packages/app/src/hooks/use-preferred-editor.test.ts @@ -20,6 +20,12 @@ describe("resolvePreferredEditorId", () => { ); }); + it("keeps custom script target ids as plain strings", () => { + expect(resolvePreferredEditorId(["script:open-in-nvim", "cursor"], "script:open-in-nvim")).toBe( + "script:open-in-nvim", + ); + }); + 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 index 9b80f97f6..15b8c1c4c 100644 --- a/packages/app/src/hooks/use-preferred-editor.ts +++ b/packages/app/src/hooks/use-preferred-editor.ts @@ -1,7 +1,7 @@ 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 "@getpaseo/protocol/messages"; +import type { EditorTargetId } from "@/workspace/editor-targets"; const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor"; const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"]; @@ -11,8 +11,7 @@ async function loadPreferredEditor(): Promise { if (!stored) { return null; } - const parsed = EditorTargetIdSchema.safeParse(stored); - return parsed.success ? parsed.data : null; + return stored.trim() || null; } export function resolvePreferredEditorId( 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 index 103dba42d..184a71802 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -6,10 +6,9 @@ import { View, type PressableStateCallbackType, } from "react-native"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { Check, ChevronDown } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import type { EditorTargetDescriptorPayload } from "@getpaseo/protocol/messages"; import { EditorAppIcon } from "@/components/icons/editor-app-icons"; import { GitHubIcon } from "@/components/icons/github-icon"; import { @@ -21,18 +20,20 @@ import { import { useToast } from "@/contexts/toast-context"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; -import { buildGitHubBranchTreeUrl } from "@/git/github-url"; import { openExternalUrl } from "@/utils/open-external-url"; import { isAbsolutePath } from "@/utils/path"; import { isWeb } from "@/constants/platform"; +import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; +import { resolveWorkspaceFilePaths, type WorkspaceFileLocation } from "@/workspace/file-open"; +import { planWorkspaceOpenTargets } from "@/workspace/open-target-planner"; import type { Theme } from "@/styles/theme"; -import { filterTargetsForDaemonLocation } from "./workspace-open-targets"; interface WorkspaceOpenInEditorButtonProps { serverId: string; cwd: string; + activeFile?: WorkspaceFileLocation | null; hideLabels?: boolean; } @@ -40,7 +41,6 @@ interface OpenTarget { id: string; label: string; icon: ReactElement; - requiresLocalDaemon: boolean; onOpen: () => Promise | void; } @@ -80,95 +80,71 @@ function OpenTargetMenuItem({ target, isPreferred, onOpen }: OpenTargetMenuItemP export function WorkspaceOpenInEditorButton({ serverId, cwd, + activeFile, hideLabels, }: WorkspaceOpenInEditorButtonProps) { const toast = useToast(); - const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const isLocalDaemon = useIsLocalDaemon(serverId); const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); + const { targets: desktopOpenTargets, isAvailable: isDesktopOpenAvailable } = + useDesktopOpenTargets({ + isLocalExecution: isLocalDaemon, + }); - const shouldQueryWorkspace = - isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd); - const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon; - - const availableEditorsQuery = useQuery({ - queryKey: ["available-editors", serverId], - enabled: shouldLoadEditorTargets, - 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 = useMemo( - () => availableEditorsQuery.data ?? [], - [availableEditorsQuery.data], + const resolvedFile = useMemo( + () => + activeFile ? resolveWorkspaceFilePaths({ path: activeFile.path, workspaceRoot: cwd }) : null, + [activeFile, cwd], ); + const activeFileName = useMemo( + () => resolvedFile?.absolutePath.split("/").findLast(Boolean) ?? null, + [resolvedFile], + ); + + const canResolveWorkspace = isWeb && cwd.trim().length > 0 && isAbsolutePath(cwd); + const shouldQueryCheckout = canResolveWorkspace && isConnected; const { status: checkoutStatus } = useCheckoutStatusQuery({ serverId, - cwd: shouldQueryWorkspace ? cwd : "", + cwd: shouldQueryCheckout ? cwd : "", }); - const editorTargets = useMemo( + const targets = useMemo( () => - availableEditors.map((editor) => ({ - id: editor.id, - label: editor.label, - icon: , - requiresLocalDaemon: true, - onOpen: async () => { - if (!client) { - throw new Error("Host is not connected"); - } - const payload = await client.openInEditor(cwd, editor.id); - if (payload.error) { - throw new Error(payload.error); - } - }, - })), - [availableEditors, client, cwd], - ); - - const githubTarget = useMemo(() => { - if (!checkoutStatus?.isGit) { - return null; - } - const url = buildGitHubBranchTreeUrl({ - remoteUrl: checkoutStatus.remoteUrl, - branch: checkoutStatus.currentBranch, - }); - if (!url) { - return null; - } - return { - id: "github", - label: "GitHub", - icon: , - requiresLocalDaemon: false, - onOpen: () => openExternalUrl(url), - }; - }, [checkoutStatus]); - - const targets = useMemo( - () => - filterTargetsForDaemonLocation( - githubTarget ? [...editorTargets, githubTarget] : editorTargets, - { - isLocalDaemon, - }, - ), - [editorTargets, githubTarget, isLocalDaemon], + planWorkspaceOpenTargets({ + workspaceDirectory: cwd, + activeFile, + resolvedActiveFile: resolvedFile, + desktopTargets: desktopOpenTargets, + canUseDesktopBridge: isDesktopOpenAvailable, + isLocalExecution: isLocalDaemon, + checkoutStatus, + }).map((target) => { + if (target.source === "github") { + return { + id: target.id, + label: target.label, + icon: , + onOpen: () => openExternalUrl(target.url), + }; + } + return { + id: target.id, + label: target.label, + icon: , + onOpen: () => openDesktopTarget(target.openInput), + }; + }), + [ + activeFile, + checkoutStatus, + cwd, + desktopOpenTargets, + isDesktopOpenAvailable, + isLocalDaemon, + resolvedFile, + ], ); const targetIds = useMemo(() => targets.map((target) => target.id), [targets]); @@ -216,7 +192,7 @@ export function WorkspaceOpenInEditorButton({ } }, [primaryOption, handleOpenTarget]); - if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) { + if (!canResolveWorkspace || !primaryOption || targets.length === 0) { return null; } @@ -229,7 +205,11 @@ export function WorkspaceOpenInEditorButton({ onPress={handlePrimaryPress} disabled={openMutation.isPending} accessibilityRole="button" - accessibilityLabel={`Open workspace in ${primaryOption.label}`} + accessibilityLabel={ + activeFileName + ? `Open ${activeFileName} in ${primaryOption.label}` + : `Open workspace in ${primaryOption.label}` + } > {openMutation.isPending ? ( { - const targets = [ - { id: "cursor", requiresLocalDaemon: true }, - { id: "vscode", requiresLocalDaemon: true }, - { id: "github", requiresLocalDaemon: false }, - ]; - - it("keeps local app targets and URL targets for the local daemon", () => { - expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: true })).toEqual(targets); - }); - - it("hides local app targets for a remote daemon", () => { - expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: false })).toEqual([ - { id: "github", requiresLocalDaemon: false }, - ]); - }); - - it("preserves target order after filtering", () => { - expect( - filterTargetsForDaemonLocation( - [ - { id: "github", requiresLocalDaemon: false }, - { id: "finder", requiresLocalDaemon: true }, - { id: "docs", requiresLocalDaemon: false }, - ], - { isLocalDaemon: false }, - ), - ).toEqual([ - { id: "github", requiresLocalDaemon: false }, - { id: "docs", requiresLocalDaemon: false }, - ]); - }); -}); diff --git a/packages/app/src/screens/workspace/workspace-open-targets.ts b/packages/app/src/screens/workspace/workspace-open-targets.ts deleted file mode 100644 index 52efe5914..000000000 --- a/packages/app/src/screens/workspace/workspace-open-targets.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface WorkspaceOpenTargetAvailability { - requiresLocalDaemon: boolean; -} - -export function filterTargetsForDaemonLocation( - targets: readonly Target[], - input: { isLocalDaemon: boolean }, -): Target[] { - if (input.isLocalDaemon) { - return [...targets]; - } - return targets.filter((target) => !target.requiresLocalDaemon); -} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index cce5f5029..1004b8a5d 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -193,6 +193,31 @@ function getWorkspaceScripts( return workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS; } +interface WorkspaceFileLocationFields { + path: string | null; + lineStart?: number; + lineEnd?: number; +} + +function getWorkspaceFileLocationFields( + tab: WorkspaceTabDescriptor | null, +): WorkspaceFileLocationFields { + const target = tab?.target; + if (target?.kind !== "file") { + return { path: null }; + } + return { path: target.path, lineStart: target.lineStart, lineEnd: target.lineEnd }; +} + +function buildWorkspaceFileLocation( + fields: WorkspaceFileLocationFields, +): WorkspaceFileLocation | null { + if (fields.path === null) { + return null; + } + return { path: fields.path, lineStart: fields.lineStart, lineEnd: fields.lineEnd }; +} + const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedEllipsisVertical = withUnistyles(EllipsisVertical); @@ -2851,6 +2876,19 @@ function WorkspaceScreenContent({ }); const activeTabDescriptor = useMemo(() => activeTab?.descriptor ?? null, [activeTab]); + const activeFileFields = getWorkspaceFileLocationFields(activeTabDescriptor); + const activeFilePath = activeFileFields.path; + const activeFileLineStart = activeFileFields.lineStart; + const activeFileLineEnd = activeFileFields.lineEnd; + const activeFileLocation = useMemo( + () => + buildWorkspaceFileLocation({ + path: activeFilePath, + lineStart: activeFileLineStart, + lineEnd: activeFileLineEnd, + }), + [activeFileLineEnd, activeFileLineStart, activeFilePath], + ); const canRenderDesktopPaneSplits = supportsDesktopPaneSplits(); const shouldRenderDesktopPaneFallback = useMemo( () => !isMobile && !canRenderDesktopPaneSplits, @@ -3083,10 +3121,11 @@ function WorkspaceScreenContent({ hideLabels={showCompactButtonLabels} /> ) : null} - {!isMobile ? ( + {!isMobile && workspaceDirectory ? ( ) : null} @@ -3193,6 +3232,8 @@ function WorkspaceScreenContent({ workspaceDescriptor, normalizedServerId, normalizedWorkspaceId, + workspaceDirectory, + activeFileLocation, liveTerminalIds, handleScriptTerminalStarted, handleViewScriptTerminal, diff --git a/packages/app/src/workspace/desktop-open-targets.ts b/packages/app/src/workspace/desktop-open-targets.ts new file mode 100644 index 000000000..63b483c40 --- /dev/null +++ b/packages/app/src/workspace/desktop-open-targets.ts @@ -0,0 +1,71 @@ +import { useQuery } from "@tanstack/react-query"; +import { getDesktopHost, type DesktopEditorBridge } from "@/desktop/host"; + +export type DesktopOpenTargetKind = "editor" | "file-manager"; +export type DesktopOpenMode = "open" | "reveal"; + +export interface DesktopOpenTarget { + id: string; + label: string; + kind: DesktopOpenTargetKind; +} + +export interface OpenDesktopTargetInput { + editorId: string; + path: string; + cwd?: string; + mode?: DesktopOpenMode; +} + +interface AvailableDesktopEditorBridge { + listTargets: NonNullable; + openTarget: NonNullable; +} + +function getDesktopEditorBridge(): AvailableDesktopEditorBridge | null { + const bridge = getDesktopHost()?.editor; + if (!bridge?.listTargets || !bridge.openTarget) { + return null; + } + return { + listTargets: bridge.listTargets, + openTarget: bridge.openTarget, + }; +} + +export function hasDesktopOpenTargetsBridge(): boolean { + return getDesktopEditorBridge() !== null; +} + +export async function listDesktopOpenTargets(): Promise { + const bridge = getDesktopEditorBridge(); + if (!bridge) { + return []; + } + return await bridge.listTargets(); +} + +export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise { + const bridge = getDesktopEditorBridge(); + if (!bridge) { + throw new Error("Desktop editor bridge is unavailable"); + } + await bridge.openTarget(input); +} + +export function useDesktopOpenTargets(input: { isLocalExecution: boolean }) { + const hasBridge = hasDesktopOpenTargetsBridge(); + const canListTargets = hasBridge && input.isLocalExecution; + const query = useQuery({ + queryKey: ["desktop-open-targets"], + enabled: canListTargets, + staleTime: 60_000, + retry: false, + queryFn: listDesktopOpenTargets, + }); + + return { + targets: query.data ?? [], + isAvailable: canListTargets, + }; +} diff --git a/packages/app/src/workspace/editor-targets.test.ts b/packages/app/src/workspace/editor-targets.test.ts new file mode 100644 index 000000000..4e5ea78ff --- /dev/null +++ b/packages/app/src/workspace/editor-targets.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { isKnownEditorTargetId } from "./editor-targets"; + +describe("editor target ids", () => { + it("recognizes built-ins without typing custom ids out of the system", () => { + const customEditorId: string = "script:open-in-nvim"; + + expect(isKnownEditorTargetId("vscode")).toBe(true); + expect(isKnownEditorTargetId(customEditorId)).toBe(false); + }); +}); diff --git a/packages/app/src/workspace/editor-targets.ts b/packages/app/src/workspace/editor-targets.ts new file mode 100644 index 000000000..a14395223 --- /dev/null +++ b/packages/app/src/workspace/editor-targets.ts @@ -0,0 +1,15 @@ +export type EditorTargetId = string; + +const KNOWN_EDITOR_TARGET_IDS: ReadonlySet = new Set([ + "cursor", + "vscode", + "webstorm", + "zed", + "finder", + "explorer", + "file-manager", +]); + +export function isKnownEditorTargetId(editorId: EditorTargetId): boolean { + return KNOWN_EDITOR_TARGET_IDS.has(editorId); +} diff --git a/packages/app/src/workspace/file-open/index.test.ts b/packages/app/src/workspace/file-open/index.test.ts index a54d791f4..6a31ac6ab 100644 --- a/packages/app/src/workspace/file-open/index.test.ts +++ b/packages/app/src/workspace/file-open/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, + resolveWorkspaceFilePaths, workspaceFileLocationsEqual, } from "."; @@ -61,3 +62,93 @@ describe("workspace file tab targets", () => { ).toBe(false); }); }); + +describe("resolveWorkspaceFilePaths", () => { + it("joins workspace-relative paths against the workspace root", () => { + expect( + resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "/Users/me/repo" }), + ).toEqual({ + absolutePath: "/Users/me/repo/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("derives the relative path for an absolute file inside the workspace", () => { + expect( + resolveWorkspaceFilePaths({ + path: "/Users/me/repo/src/app.ts", + workspaceRoot: "/Users/me/repo", + }), + ).toEqual({ + absolutePath: "/Users/me/repo/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("keeps the absolute path but drops the relative path when outside the workspace", () => { + expect( + resolveWorkspaceFilePaths({ + path: "/etc/hosts", + workspaceRoot: "/Users/me/repo", + }), + ).toEqual({ + absolutePath: "/etc/hosts", + relativePath: null, + }); + }); + + it("normalizes Windows separators in the file path", () => { + expect( + resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }), + ).toEqual({ + absolutePath: "/Users/me/repo/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("normalizes dot segments in workspace-relative paths", () => { + expect( + resolveWorkspaceFilePaths({ path: "./src/../app.ts", workspaceRoot: "/Users/me/repo" }), + ).toEqual({ + absolutePath: "/Users/me/repo/app.ts", + relativePath: "app.ts", + }); + }); + + it("rejects workspace-relative paths that escape the workspace", () => { + expect( + resolveWorkspaceFilePaths({ path: "../outside.ts", workspaceRoot: "/Users/me/repo" }), + ).toBeNull(); + expect( + resolveWorkspaceFilePaths({ path: "src/../../outside.ts", workspaceRoot: "/Users/me/repo" }), + ).toBeNull(); + }); + + it("derives the relative path for a Windows absolute file inside the workspace", () => { + expect( + resolveWorkspaceFilePaths({ + path: "C:\\Users\\me\\repo\\src\\app.ts", + workspaceRoot: "C:\\Users\\me\\repo", + }), + ).toEqual({ + absolutePath: "C:/Users/me/repo/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("returns null for home-relative paths that cannot be anchored", () => { + expect( + resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }), + ).toBeNull(); + }); + + it("returns null when the workspace root is not absolute", () => { + expect(resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "repo" })).toBeNull(); + }); + + it("returns null when the resolved file equals the workspace root", () => { + expect( + resolveWorkspaceFilePaths({ path: "/Users/me/repo", workspaceRoot: "/Users/me/repo" }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/workspace/file-open/index.ts b/packages/app/src/workspace/file-open/index.ts index c79c77fa0..4a14a78a5 100644 --- a/packages/app/src/workspace/file-open/index.ts +++ b/packages/app/src/workspace/file-open/index.ts @@ -1,3 +1,5 @@ +import { isAbsolutePath } from "@/utils/path"; + export type OpenFileDisposition = "main" | "side"; export interface WorkspaceFileLocation { @@ -57,3 +59,103 @@ function normalizeLineNumber(value: number | null | undefined): number | undefin ? Math.floor(value) : undefined; } + +function trimTrailingSlashes(value: string): string { + if (value === "/" || /^\/+$/.test(value)) { + return "/"; + } + if (/^[A-Za-z]:\/+$/.test(value)) { + return `${value.slice(0, 2)}/`; + } + return value.replace(/\/+$/, ""); +} + +function normalizePathSegments(value: string, rejectEscape: boolean): string | null { + const segments: string[] = []; + for (const segment of value.split("/")) { + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length === 0) { + if (rejectEscape) { + return null; + } + continue; + } + segments.pop(); + continue; + } + segments.push(segment); + } + return segments.join("/"); +} + +function normalizeAbsolutePath(value: string): string | null { + const normalizedInput = trimTrailingSlashes(value); + if (!isAbsolutePath(normalizedInput)) { + return null; + } + + const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput); + if (drivePath) { + const normalizedBody = normalizePathSegments(drivePath[2], false); + return trimTrailingSlashes(`${drivePath[1]}/${normalizedBody}`); + } + + const prefix = normalizedInput.startsWith("//") ? "//" : "/"; + const normalizedBody = normalizePathSegments(normalizedInput.replace(/^\/+/, ""), false); + return trimTrailingSlashes(`${prefix}${normalizedBody}`); +} + +function normalizeRelativePath(value: string): string | null { + const normalized = normalizePathSegments(value.replace(/^\/+/, ""), true); + return normalized || null; +} + +export interface ResolvedWorkspaceFilePaths { + /** Absolute path on the host, suitable for opening in an editor / file manager. */ + absolutePath: string; + /** Path relative to the workspace root, or null when the file lives outside it. */ + relativePath: string | null; +} + +/** + * Resolves a file tab's path (which may be workspace-relative) against the workspace + * root. Returns null when an absolute host path cannot be derived — e.g. a `~`-relative + * path or a relative path with no workspace root to anchor it. + */ +export function resolveWorkspaceFilePaths(input: { + path: string; + workspaceRoot: string; +}): ResolvedWorkspaceFilePaths | null { + const filePath = input.path.trim().replace(/\\/g, "/"); + const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/")); + if (!filePath || !workspaceRoot) { + return null; + } + + if (isAbsolutePath(filePath)) { + const normalizedFile = normalizeAbsolutePath(filePath); + if (!normalizedFile) { + return null; + } + if (normalizedFile === workspaceRoot) { + return null; + } + const relativePath = normalizedFile.startsWith(`${workspaceRoot}/`) + ? normalizedFile.slice(workspaceRoot.length + 1) + : null; + return { absolutePath: normalizedFile, relativePath }; + } + + if (filePath.startsWith("~")) { + return null; + } + + const relativePath = normalizeRelativePath(filePath); + if (!relativePath) { + return null; + } + return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath }; +} diff --git a/packages/app/src/workspace/open-target-planner.test.ts b/packages/app/src/workspace/open-target-planner.test.ts new file mode 100644 index 000000000..19acff6cf --- /dev/null +++ b/packages/app/src/workspace/open-target-planner.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { planWorkspaceOpenTargets } from "./open-target-planner"; + +const desktopTargets = [ + { id: "vscode", label: "VS Code", kind: "editor" as const }, + { id: "finder", label: "Finder", kind: "file-manager" as const }, +]; + +const checkoutStatus = { + isGit: true, + remoteUrl: "git@github.com:getpaseo/paseo.git", + currentBranch: "main", +}; + +describe("planWorkspaceOpenTargets", () => { + it("plans editor targets with active-file absolute path and cwd", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + activeFile: { path: "src/app.ts", lineStart: 3, lineEnd: 5 }, + desktopTargets, + canUseDesktopBridge: true, + isLocalExecution: true, + }); + + expect(targets[0]).toMatchObject({ + source: "desktop", + id: "vscode", + openInput: { editorId: "vscode", path: "/repo/src/app.ts", cwd: "/repo" }, + }); + }); + + it("plans file-manager targets with active-file absolute path and reveal mode", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + activeFile: { path: "src/app.ts" }, + desktopTargets, + canUseDesktopBridge: true, + isLocalExecution: true, + }); + + expect(targets[1]).toMatchObject({ + source: "desktop", + id: "finder", + openInput: { editorId: "finder", path: "/repo/src/app.ts", mode: "reveal" }, + }); + }); + + it("plans no active file as opening the workspace folder", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + desktopTargets, + canUseDesktopBridge: true, + isLocalExecution: true, + }); + + expect(targets[0]).toMatchObject({ + source: "desktop", + id: "vscode", + openInput: { editorId: "vscode", path: "/repo" }, + }); + expect(targets[1]).toMatchObject({ + source: "desktop", + id: "finder", + openInput: { editorId: "finder", path: "/repo" }, + }); + }); + + it("passes custom target ids through as strings", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + activeFile: { path: "src/app.ts" }, + desktopTargets: [{ id: "script:open-in-nvim", label: "Open in Neovim", kind: "editor" }], + canUseDesktopBridge: true, + isLocalExecution: true, + }); + + expect(targets).toEqual([ + { + source: "desktop", + id: "script:open-in-nvim", + label: "Open in Neovim", + editorId: "script:open-in-nvim", + openInput: { + editorId: "script:open-in-nvim", + path: "/repo/src/app.ts", + cwd: "/repo", + }, + }, + ]); + }); + + it("keeps GitHub target independent and uses blob and tree URLs", () => { + const blobTargets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + activeFile: { path: "src/app.ts", lineStart: 3, lineEnd: 5 }, + desktopTargets: [], + canUseDesktopBridge: false, + isLocalExecution: false, + checkoutStatus, + }); + const treeTargets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + desktopTargets: [], + canUseDesktopBridge: false, + isLocalExecution: false, + checkoutStatus, + }); + + expect(blobTargets).toEqual([ + { + source: "github", + id: "github", + label: "GitHub", + url: "https://github.com/getpaseo/paseo/blob/main/src/app.ts#L3-L5", + }, + ]); + expect(treeTargets).toEqual([ + { + source: "github", + id: "github", + label: "GitHub", + url: "https://github.com/getpaseo/paseo/tree/main", + }, + ]); + }); + + it("suppresses desktop targets when Electron bridge is unavailable", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + desktopTargets, + canUseDesktopBridge: false, + isLocalExecution: true, + checkoutStatus, + }); + + expect(targets.map((target) => target.id)).toEqual(["github"]); + }); + + it("suppresses desktop targets for remote execution paths", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + desktopTargets, + canUseDesktopBridge: true, + isLocalExecution: false, + checkoutStatus, + }); + + expect(targets.map((target) => target.id)).toEqual(["github"]); + }); +}); diff --git a/packages/app/src/workspace/open-target-planner.ts b/packages/app/src/workspace/open-target-planner.ts new file mode 100644 index 000000000..9539df4f0 --- /dev/null +++ b/packages/app/src/workspace/open-target-planner.ts @@ -0,0 +1,146 @@ +import { buildGitHubBlobUrl, buildGitHubBranchTreeUrl } from "@/git/github-url"; +import type { DesktopOpenTarget, OpenDesktopTargetInput } from "@/workspace/desktop-open-targets"; +import { + type ResolvedWorkspaceFilePaths, + resolveWorkspaceFilePaths, + type WorkspaceFileLocation, +} from "@/workspace/file-open"; + +interface CheckoutStatusForOpenTarget { + isGit: boolean; + remoteUrl?: string | null; + currentBranch?: string | null; +} + +export interface PlannedDesktopOpenTarget { + source: "desktop"; + id: string; + label: string; + editorId: string; + openInput: OpenDesktopTargetInput; +} + +export interface PlannedGitHubOpenTarget { + source: "github"; + id: "github"; + label: "GitHub"; + url: string; +} + +export type PlannedWorkspaceOpenTarget = PlannedDesktopOpenTarget | PlannedGitHubOpenTarget; + +export interface PlanWorkspaceOpenTargetsInput { + workspaceDirectory: string; + activeFile?: WorkspaceFileLocation | null; + resolvedActiveFile?: ResolvedWorkspaceFilePaths | null; + desktopTargets: readonly DesktopOpenTarget[]; + canUseDesktopBridge: boolean; + isLocalExecution: boolean; + checkoutStatus?: CheckoutStatusForOpenTarget | null; +} + +function resolveActiveFileForOpenTargets( + input: Pick< + PlanWorkspaceOpenTargetsInput, + "activeFile" | "resolvedActiveFile" | "workspaceDirectory" + >, +): ResolvedWorkspaceFilePaths | null { + if (input.resolvedActiveFile !== undefined) { + return input.resolvedActiveFile; + } + return input.activeFile + ? resolveWorkspaceFilePaths({ + path: input.activeFile.path, + workspaceRoot: input.workspaceDirectory, + }) + : null; +} + +function planDesktopOpenTargets(input: { + workspaceDirectory: string; + resolvedFile: ResolvedWorkspaceFilePaths | null; + desktopTargets: readonly DesktopOpenTarget[]; + canUseDesktopBridge: boolean; + isLocalExecution: boolean; +}): PlannedDesktopOpenTarget[] { + if (!input.canUseDesktopBridge || !input.isLocalExecution) { + return []; + } + + return input.desktopTargets.map((target) => { + if (!input.resolvedFile) { + return { + source: "desktop", + id: target.id, + label: target.label, + editorId: target.id, + openInput: { editorId: target.id, path: input.workspaceDirectory }, + }; + } + if (target.kind === "editor") { + return { + source: "desktop", + id: target.id, + label: target.label, + editorId: target.id, + openInput: { + editorId: target.id, + path: input.resolvedFile.absolutePath, + cwd: input.workspaceDirectory, + }, + }; + } + return { + source: "desktop", + id: target.id, + label: target.label, + editorId: target.id, + openInput: { + editorId: target.id, + path: input.resolvedFile.absolutePath, + mode: "reveal", + }, + }; + }); +} + +function planGitHubOpenTarget(input: { + activeFile?: WorkspaceFileLocation | null; + resolvedFile: ResolvedWorkspaceFilePaths | null; + checkoutStatus?: CheckoutStatusForOpenTarget | null; +}): PlannedGitHubOpenTarget | null { + if (!input.checkoutStatus?.isGit) { + return null; + } + const url = input.resolvedFile?.relativePath + ? buildGitHubBlobUrl({ + remoteUrl: input.checkoutStatus.remoteUrl, + branch: input.checkoutStatus.currentBranch, + path: input.resolvedFile.relativePath, + lineStart: input.activeFile?.lineStart, + lineEnd: input.activeFile?.lineEnd, + }) + : buildGitHubBranchTreeUrl({ + remoteUrl: input.checkoutStatus.remoteUrl, + branch: input.checkoutStatus.currentBranch, + }); + + if (!url) { + return null; + } + return { + source: "github", + id: "github", + label: "GitHub", + url, + }; +} + +export function planWorkspaceOpenTargets( + input: PlanWorkspaceOpenTargetsInput, +): PlannedWorkspaceOpenTarget[] { + const resolvedFile = resolveActiveFileForOpenTargets(input); + const desktopTargets = planDesktopOpenTargets({ ...input, resolvedFile }); + const githubTarget = planGitHubOpenTarget({ ...input, resolvedFile }); + return githubTarget ? [...desktopTargets, githubTarget] : desktopTargets; +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index a43ca6e09..5a90120cc 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -50,8 +50,6 @@ import type { PaseoWorktreeListResponse, PaseoWorktreeArchiveResponse, ProjectIconResponse, - ListAvailableEditorsResponseMessage, - OpenInEditorResponseMessage, OpenProjectResponseMessage, ArchiveWorkspaceResponseMessage, WorkspaceSetupStatusResponseMessage, @@ -77,7 +75,6 @@ import type { SessionInboundMessage, SessionOutboundMessage, SendAgentMessageRequest, - EditorTargetId, PaseoConfigRaw, PaseoConfigRevision, } from "@getpaseo/protocol/messages"; @@ -641,12 +638,9 @@ export interface RenameTerminalInput { title: string; requestId?: string; } -type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"]; -type OpenInEditorPayload = OpenInEditorResponseMessage["payload"]; type OpenProjectPayload = OpenProjectResponseMessage["payload"]; type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"]; type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"]; -export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number]; export interface FetchAgentResult { agent: AgentSnapshotPayload; @@ -1778,34 +1772,6 @@ export class DaemonClient { }); } - async listAvailableEditors(requestId?: string): Promise { - 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/desktop/src/features/editor-targets.test.ts b/packages/desktop/src/features/editor-targets.test.ts new file mode 100644 index 000000000..d31a51057 --- /dev/null +++ b/packages/desktop/src/features/editor-targets.test.ts @@ -0,0 +1,459 @@ +import type { SpawnOptions } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { + listAvailableEditorTargets, + openEditorTarget, + registerEditorTargetHandlers, +} from "./editor-targets"; + +interface SpawnCall { + command: string; + args: string[]; + options: SpawnOptions; + listenedForError: boolean; + listenedForSpawn: boolean; + unrefed: boolean; +} + +function createExistsSync(paths: readonly string[]): (path: string) => boolean { + const availablePaths = new Set(paths); + return (path) => availablePaths.has(path); +} + +function createSpawnRecorder() { + const calls: SpawnCall[] = []; + const spawn = (command: string, args: string[], options: SpawnOptions) => { + const call: SpawnCall = { + command, + args, + options, + listenedForError: false, + listenedForSpawn: false, + unrefed: false, + }; + calls.push(call); + const child = { + once: (event: "error" | "spawn", handler: (error?: Error) => void) => { + if (event === "error") { + call.listenedForError = true; + } + if (event === "spawn") { + call.listenedForSpawn = true; + queueMicrotask(() => handler()); + } + return child; + }, + unref: () => { + call.unrefed = true; + }, + }; + return child; + }; + return { calls, spawn }; +} + +describe("desktop editor targets", () => { + it("lists all known editor targets for the platform in deterministic order", () => { + const targets = listAvailableEditorTargets({ + platform: "win32", + env: { PATH: "C:/bin" }, + existsSync: createExistsSync([ + "C:/bin/cursor.exe", + "C:/bin/code.exe", + "C:/bin/webstorm.exe", + "C:/bin/zed.exe", + "C:/bin/explorer.exe", + ]), + }); + + expect(targets).toEqual([ + { id: "cursor", label: "Cursor", kind: "editor" }, + { id: "vscode", label: "VS Code", kind: "editor" }, + { id: "webstorm", label: "WebStorm", kind: "editor" }, + { id: "zed", label: "Zed", kind: "editor" }, + { id: "explorer", label: "Explorer", kind: "file-manager" }, + ]); + }); + + it("lists Finder on macOS", () => { + const targets = listAvailableEditorTargets({ + platform: "darwin", + env: { PATH: "/usr/bin" }, + existsSync: createExistsSync(["/usr/bin/open"]), + }); + + expect(targets).toEqual([{ id: "finder", label: "Finder", kind: "file-manager" }]); + }); + + it("lists the generic file manager on Linux", () => { + const targets = listAvailableEditorTargets({ + platform: "linux", + env: { PATH: "/usr/bin" }, + existsSync: createExistsSync(["/usr/bin/xdg-open"]), + }); + + expect(targets).toEqual([{ id: "file-manager", label: "File Manager", kind: "file-manager" }]); + }); + + it("can list future custom script targets without changing bridge types", () => { + const targets = listAvailableEditorTargets({ + platform: "linux", + env: { PATH: "/usr/local/bin" }, + existsSync: createExistsSync(["/usr/local/bin/open-in-nvim"]), + targetDefinitions: [ + { + id: "script:open-in-nvim", + label: "Open in Neovim", + kind: "editor", + command: "open-in-nvim", + }, + ], + }); + + expect(targets).toEqual([ + { id: "script:open-in-nvim", label: "Open in Neovim", kind: "editor" }, + ]); + }); + + it("launches editors as detached external processes", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "vscode", path: "/tmp/repo" }, + { + platform: "darwin", + env: { PATH: "/usr/local/bin", ELECTRON_RUN_AS_NODE: "1" }, + existsSync: createExistsSync(["/tmp/repo", "/usr/local/bin/code"]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls).toEqual([ + { + command: "/usr/local/bin/code", + args: ["/tmp/repo"], + options: { + detached: true, + env: { PATH: "/usr/local/bin" }, + shell: false, + stdio: "ignore", + }, + listenedForError: true, + listenedForSpawn: true, + unrefed: true, + }, + ]); + }); + + it("reveals files in Finder on macOS", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "finder", path: "/tmp/repo/src/index.ts", mode: "reveal" }, + { + platform: "darwin", + env: { PATH: "/usr/bin" }, + existsSync: createExistsSync(["/tmp/repo/src/index.ts", "/usr/bin/open"]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]?.command).toBe("/usr/bin/open"); + expect(recorder.calls[0]?.args).toEqual(["-R", "/tmp/repo/src/index.ts"]); + }); + + it("reveals files in Explorer on Windows", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "explorer", path: "C:/repo/src/index.ts", mode: "reveal" }, + { + platform: "win32", + env: { PATH: "C:/Windows" }, + existsSync: createExistsSync(["C:/repo/src/index.ts", "C:/Windows/explorer.exe"]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]?.command).toBe("C:/Windows/explorer.exe"); + expect(recorder.calls[0]?.args).toEqual(["/select,", "C:/repo/src/index.ts"]); + expect(recorder.calls[0]?.options.shell).toBe(false); + }); + + it("keeps Windows shell metacharacters literal for direct executables", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "explorer", path: "C:/repo/src/file & calculator.ts", mode: "reveal" }, + { + platform: "win32", + env: { PATH: "C:/Windows" }, + existsSync: createExistsSync([ + "C:/repo/src/file & calculator.ts", + "C:/Windows/explorer.exe", + ]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]).toMatchObject({ + command: "C:/Windows/explorer.exe", + args: ["/select,", "C:/repo/src/file & calculator.ts"], + options: { shell: false }, + }); + }); + + it("quotes Windows command-script paths without corrupting metacharacters", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { + editorId: "vscode", + path: "C:/repo/src/file & calculator.ts", + cwd: "C:/repo & workspace", + }, + { + platform: "win32", + env: { PATH: "C:/Program Files/Editors & Tools/bin" }, + existsSync: createExistsSync([ + "C:/repo/src/file & calculator.ts", + "C:/repo & workspace", + "C:/Program Files/Editors & Tools/bin/code.cmd", + ]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]).toMatchObject({ + command: '"C:/Program Files/Editors & Tools/bin/code.cmd"', + args: ['"C:/repo & workspace"', '"C:/repo/src/file & calculator.ts"'], + options: { shell: true }, + }); + }); + + it("quotes Windows command-script values that contain shell metacharacters", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { + editorId: "vscode", + path: "C:/repo/src/file&calculator.ts", + }, + { + platform: "win32", + env: { PATH: "C:/Editors&Tools/bin" }, + existsSync: createExistsSync([ + "C:/repo/src/file&calculator.ts", + "C:/Editors&Tools/bin/code.cmd", + ]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]).toMatchObject({ + command: '"C:/Editors&Tools/bin/code.cmd"', + args: ['"C:/repo/src/file&calculator.ts"'], + options: { shell: true }, + }); + }); + + it("reveals Linux files by opening the containing folder", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "file-manager", path: "/home/user/repo/src/index.ts", mode: "reveal" }, + { + platform: "linux", + env: { PATH: "/usr/bin" }, + existsSync: createExistsSync(["/home/user/repo/src/index.ts", "/usr/bin/xdg-open"]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]?.args).toEqual(["/home/user/repo/src"]); + }); + + it("ignores reveal mode for editor targets", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "vscode", path: "/home/user/repo/src/index.ts", mode: "reveal" }, + { + platform: "linux", + env: { PATH: "/usr/bin" }, + existsSync: createExistsSync(["/home/user/repo/src/index.ts", "/usr/bin/code"]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]?.args).toEqual(["/home/user/repo/src/index.ts"]); + }); + + it("opens the workspace folder alongside the file for editor targets", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "vscode", path: "/tmp/repo/src/index.ts", cwd: "/tmp/repo" }, + { + platform: "darwin", + env: { PATH: "/usr/local/bin" }, + existsSync: createExistsSync([ + "/tmp/repo/src/index.ts", + "/tmp/repo", + "/usr/local/bin/code", + ]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]?.args).toEqual(["/tmp/repo", "/tmp/repo/src/index.ts"]); + }); + + it("can launch future custom script targets by string id", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "script:open-in-nvim", path: "/tmp/repo/src/index.ts", cwd: "/tmp/repo" }, + { + platform: "linux", + env: { PATH: "/usr/local/bin" }, + existsSync: createExistsSync([ + "/tmp/repo/src/index.ts", + "/tmp/repo", + "/usr/local/bin/open-in-nvim", + ]), + spawn: recorder.spawn, + targetDefinitions: [ + { + id: "script:open-in-nvim", + label: "Open in Neovim", + kind: "editor", + command: "open-in-nvim", + }, + ], + }, + ); + + expect(recorder.calls[0]).toMatchObject({ + command: "/usr/local/bin/open-in-nvim", + args: ["/tmp/repo", "/tmp/repo/src/index.ts"], + options: { shell: false }, + }); + }); + + it("does not prepend invalid, equal, relative, or missing cwd values", async () => { + const inputs = [ + { editorId: "vscode", path: "/tmp/repo", cwd: "/tmp/repo" }, + { editorId: "vscode", path: "/tmp/repo/src/index.ts", cwd: "repo" }, + { editorId: "vscode", path: "/tmp/repo/src/index.ts", cwd: "/tmp/missing" }, + ]; + + for (const input of inputs) { + const recorder = createSpawnRecorder(); + await openEditorTarget(input, { + platform: "darwin", + env: { PATH: "/usr/local/bin" }, + existsSync: createExistsSync([ + "/tmp/repo", + "/tmp/repo/src/index.ts", + "/usr/local/bin/code", + ]), + spawn: recorder.spawn, + }); + + expect(recorder.calls[0]?.args).toEqual([input.path]); + } + }); + + it("does not prepend cwd for file-manager targets", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "finder", path: "/tmp/repo/src", cwd: "/tmp/repo" }, + { + platform: "darwin", + env: { PATH: "/usr/bin" }, + existsSync: createExistsSync(["/tmp/repo/src", "/tmp/repo", "/usr/bin/open"]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]?.args).toEqual(["/tmp/repo/src"]); + }); + + it("rejects relative and missing paths", async () => { + await expect( + openEditorTarget( + { editorId: "cursor", path: "repo" }, + { existsSync: () => true, env: { PATH: "/bin" } }, + ), + ).rejects.toThrow("Editor target path must be an absolute local path"); + + await expect( + openEditorTarget( + { editorId: "cursor", path: "/tmp/repo" }, + { existsSync: () => false, env: { PATH: "/bin" } }, + ), + ).rejects.toThrow("Path does not exist: /tmp/repo"); + }); + + it("rejects unsupported, unknown, and missing executable targets", async () => { + await expect( + openEditorTarget( + { editorId: "finder", path: "/tmp/repo" }, + { platform: "linux", existsSync: () => true, env: { PATH: "/bin" } }, + ), + ).rejects.toThrow("Editor target unavailable: Finder"); + + await expect( + openEditorTarget( + { editorId: "unknown-editor", path: "/tmp/repo" }, + { existsSync: () => true, env: { PATH: "/bin" } }, + ), + ).rejects.toThrow("Unknown editor target: unknown-editor"); + + await expect( + openEditorTarget( + { editorId: "vscode", path: "/tmp/repo" }, + { existsSync: (path) => path === "/tmp/repo", env: { PATH: "/bin" } }, + ), + ).rejects.toThrow("Editor target unavailable: VS Code"); + }); + + it("runs list and open behavior through registered IPC handlers", async () => { + const handlers = new Map unknown>(); + const ipc = { + handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => { + handlers.set(channel, listener); + }, + }; + const recorder = createSpawnRecorder(); + + registerEditorTargetHandlers({ + ipc, + dependencies: { + platform: "darwin", + env: { PATH: "/usr/local/bin:/usr/bin" }, + existsSync: createExistsSync(["/tmp/repo", "/usr/local/bin/code", "/usr/bin/open"]), + spawn: recorder.spawn, + }, + }); + + const listHandler = handlers.get("paseo:editor:listTargets"); + const openHandler = handlers.get("paseo:editor:openTarget"); + if (!listHandler || !openHandler) { + throw new Error("editor IPC handlers were not registered"); + } + + expect(listHandler({})).toEqual([ + { id: "vscode", label: "VS Code", kind: "editor" }, + { id: "finder", label: "Finder", kind: "file-manager" }, + ]); + await openHandler({}, { editorId: "vscode", path: "/tmp/repo" }); + await expect(openHandler({}, { editorId: "vscode", path: "repo" })).rejects.toThrow( + "Editor target path must be an absolute local path", + ); + + expect(recorder.calls[0]?.args).toEqual(["/tmp/repo"]); + }); +}); diff --git a/packages/desktop/src/features/editor-targets.ts b/packages/desktop/src/features/editor-targets.ts new file mode 100644 index 000000000..ea70a1ab4 --- /dev/null +++ b/packages/desktop/src/features/editor-targets.ts @@ -0,0 +1,353 @@ +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { spawn as nodeSpawn } from "node:child_process"; +import { existsSync as nodeExistsSync } from "node:fs"; +import { posix, win32 } from "node:path"; +import { ipcMain } from "electron"; +import { z } from "zod"; + +type EditorTargetKind = "editor" | "file-manager"; +type OpenEditorMode = "open" | "reveal"; + +interface EditorTargetDefinition { + id: string; + label: string; + kind: EditorTargetKind; + command: string; + platforms?: readonly NodeJS.Platform[]; + excludedPlatforms?: readonly NodeJS.Platform[]; +} + +export interface DesktopEditorTargetDescriptor { + id: string; + label: string; + kind: EditorTargetKind; +} + +export interface OpenEditorTargetInput { + editorId: string; + path: string; + cwd?: string; + mode?: OpenEditorMode; +} + +interface ListEditorTargetsDependencies { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + existsSync?: (path: string) => boolean; + targetDefinitions?: readonly EditorTargetDefinition[]; +} + +interface SpawnedProcess { + once(event: "error", handler: (error: Error) => void): SpawnedProcess; + once(event: "spawn", handler: () => void): SpawnedProcess; + unref(): void; +} + +interface OpenEditorTargetDependencies extends ListEditorTargetsDependencies { + spawn?: (command: string, args: string[], options: SpawnOptions) => SpawnedProcess; +} + +interface IpcHandlerRegistry { + handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void; +} + +interface Launch { + command: string; + args: string[]; +} + +interface SpawnLaunch extends Launch { + shell: boolean; +} + +const RUNTIME_CONTROL_ENV_KEYS = [ + "PASEO_NODE_ENV", + "PASEO_DESKTOP_MANAGED", + "PASEO_SUPERVISED", + "ELECTRON_RUN_AS_NODE", + "ELECTRON_NO_ATTACH_CONSOLE", +] as const; + +const BUILT_IN_EDITOR_TARGETS: readonly EditorTargetDefinition[] = [ + { id: "cursor", label: "Cursor", kind: "editor", command: "cursor" }, + { id: "vscode", label: "VS Code", kind: "editor", command: "code" }, + { id: "webstorm", label: "WebStorm", kind: "editor", command: "webstorm" }, + { id: "zed", label: "Zed", kind: "editor", command: "zed" }, + { + id: "finder", + label: "Finder", + kind: "file-manager", + command: "open", + platforms: ["darwin"], + }, + { + id: "explorer", + label: "Explorer", + kind: "file-manager", + command: "explorer", + platforms: ["win32"], + }, + { + id: "file-manager", + label: "File Manager", + kind: "file-manager", + command: "xdg-open", + excludedPlatforms: ["darwin", "win32"], + }, +]; + +const OpenEditorTargetInputSchema = z.object({ + editorId: z.string().trim().min(1), + path: z.string().trim().min(1), + cwd: z.string().trim().min(1).optional(), + mode: z.enum(["open", "reveal"]).optional(), +}); + +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 createExternalProcessEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env = { ...baseEnv }; + for (const key of RUNTIME_CONTROL_ENV_KEYS) { + delete env[key]; + } + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete env[key]; + } + } + return env; +} + +function resolveExecutable( + command: string, + input: { + env: NodeJS.ProcessEnv; + existsSync: (path: string) => boolean; + platform: NodeJS.Platform; + }, +): string | null { + if (isAbsolutePath(command, input.platform) && input.existsSync(command)) { + return command; + } + const pathValue = input.env.PATH ?? input.env.Path ?? input.env.path ?? ""; + const pathDelimiter = input.platform === "win32" ? ";" : ":"; + for (const directory of pathValue.split(pathDelimiter)) { + if (!directory) { + continue; + } + const candidate = `${directory}/${command}`; + if (input.existsSync(candidate)) { + return candidate; + } + if (input.platform === "win32") { + for (const extension of [".exe", ".cmd"]) { + const windowsCandidate = `${candidate}${extension}`; + if (input.existsSync(windowsCandidate)) { + return windowsCandidate; + } + } + } + } + return null; +} + +function resolveTargetDefinitions( + dependencies: ListEditorTargetsDependencies, +): readonly EditorTargetDefinition[] { + return dependencies.targetDefinitions ?? BUILT_IN_EDITOR_TARGETS; +} + +function findTarget( + targetId: string, + targetDefinitions: readonly EditorTargetDefinition[], +): EditorTargetDefinition { + const target = targetDefinitions.find((entry) => entry.id === targetId); + if (!target) { + throw new Error(`Unknown editor target: ${targetId}`); + } + return target; +} + +function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean { + return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value); +} + +function dirnameForPlatform(value: string, platform: NodeJS.Platform): string { + return platform === "win32" ? win32.dirname(value) : posix.dirname(value); +} + +function isWindowsCommandScript(executable: string, platform: NodeJS.Platform): boolean { + if (platform !== "win32") { + return false; + } + const extension = win32.extname(executable).toLowerCase(); + return extension === ".cmd" || extension === ".bat"; +} + +function escapeWindowsCmdValue(value: string): string { + const isQuoted = value.startsWith('"') && value.endsWith('"'); + const unquoted = isQuoted ? value.slice(1, -1) : value; + + if (isQuoted || /[\s"&|^<>()!]/u.test(unquoted)) { + const quoted = unquoted + .replace(/(\\*)"/g, (_match, slashes: string) => `${slashes}${slashes}\\"`) + .replace(/\\+$/u, (slashes) => `${slashes}${slashes}`); + return `"${quoted}"`; + } + + return unquoted; +} + +function createSpawnLaunch(launch: Launch, platform: NodeJS.Platform): SpawnLaunch { + if (!isWindowsCommandScript(launch.command, platform)) { + return { ...launch, shell: false }; + } + + return { + command: escapeWindowsCmdValue(launch.command), + args: launch.args.map(escapeWindowsCmdValue), + shell: true, + }; +} + +function buildLaunch(input: { + target: EditorTargetDefinition; + path: string; + cwd?: string; + mode: OpenEditorMode; + platform: NodeJS.Platform; + executable: string; +}): Launch { + if (input.mode === "reveal") { + if (input.target.id === "finder" && input.platform === "darwin") { + return { command: input.executable, args: ["-R", input.path] }; + } + if (input.target.id === "explorer" && input.platform === "win32") { + return { command: input.executable, args: ["/select,", input.path] }; + } + if (input.target.id === "file-manager") { + return { command: input.executable, args: [dirnameForPlatform(input.path, input.platform)] }; + } + } + + if (input.target.kind === "editor" && input.cwd && input.cwd !== input.path) { + return { command: input.executable, args: [input.cwd, input.path] }; + } + return { command: input.executable, args: [input.path] }; +} + +function spawnDetachedProcess( + command: string, + args: string[], + options: SpawnOptions, +): SpawnedProcess { + return nodeSpawn(command, args, options) as ChildProcess as SpawnedProcess; +} + +export function listAvailableEditorTargets( + dependencies: ListEditorTargetsDependencies = {}, +): DesktopEditorTargetDescriptor[] { + const platform = dependencies.platform ?? process.platform; + const existsSync = dependencies.existsSync ?? nodeExistsSync; + const env = dependencies.env ?? process.env; + + const targetDefinitions = resolveTargetDefinitions(dependencies); + + return targetDefinitions + .filter((target) => isTargetSupportedOnPlatform(target, platform)) + .filter((target) => resolveExecutable(target.command, { platform, env, existsSync })) + .map((target) => ({ id: target.id, label: target.label, kind: target.kind })); +} + +export async function openEditorTarget( + input: OpenEditorTargetInput, + dependencies: OpenEditorTargetDependencies = {}, +): Promise { + const parsedInput = OpenEditorTargetInputSchema.parse(input); + const platform = dependencies.platform ?? process.platform; + const existsSync = dependencies.existsSync ?? nodeExistsSync; + const env = dependencies.env ?? process.env; + const spawn = dependencies.spawn ?? spawnDetachedProcess; + const pathToOpen = parsedInput.path; + + if (!isAbsolutePath(pathToOpen, platform)) { + throw new Error("Editor target path must be an absolute local path"); + } + if (!existsSync(pathToOpen)) { + throw new Error(`Path does not exist: ${pathToOpen}`); + } + + const target = findTarget(parsedInput.editorId, resolveTargetDefinitions(dependencies)); + if (!isTargetSupportedOnPlatform(target, platform)) { + throw new Error(`Editor target unavailable: ${target.label}`); + } + + const executable = resolveExecutable(target.command, { platform, env, existsSync }); + if (!executable) { + throw new Error(`Editor target unavailable: ${target.label}`); + } + + const workspaceCwd = + parsedInput.cwd && + isAbsolutePath(parsedInput.cwd, platform) && + parsedInput.cwd !== pathToOpen && + existsSync(parsedInput.cwd) + ? parsedInput.cwd + : undefined; + const launch = buildLaunch({ + target, + path: pathToOpen, + cwd: workspaceCwd, + mode: parsedInput.mode ?? "open", + platform, + executable, + }); + const spawnLaunch = createSpawnLaunch(launch, platform); + + await new Promise((resolve, reject) => { + let child: SpawnedProcess; + try { + child = spawn(spawnLaunch.command, spawnLaunch.args, { + detached: true, + env: createExternalProcessEnv(env), + shell: spawnLaunch.shell, + stdio: "ignore", + }); + } catch (error) { + reject(error); + return; + } + + child.once("error", reject); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); +} + +export function registerEditorTargetHandlers( + options: { + ipc?: IpcHandlerRegistry; + dependencies?: OpenEditorTargetDependencies; + } = {}, +): void { + const ipc = options.ipc ?? ipcMain; + const dependencies = options.dependencies ?? {}; + ipc.handle("paseo:editor:listTargets", () => listAvailableEditorTargets(dependencies)); + ipc.handle("paseo:editor:openTarget", async (_event, payload: unknown) => { + const parsedInput = OpenEditorTargetInputSchema.parse(payload); + await openEditorTarget(parsedInput, dependencies); + }); +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 3076ddb0f..42324bf03 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -43,6 +43,7 @@ import { ensureNotificationCenterRegistration, } from "./features/notifications.js"; import { registerOpenerHandlers } from "./features/opener.js"; +import { registerEditorTargetHandlers } from "./features/editor-targets.js"; import { setupApplicationMenu } from "./features/menu.js"; import { getPaseoBrowserIdForWebContents, @@ -697,6 +698,7 @@ async function bootstrap(): Promise { registerDialogHandlers(); registerNotificationHandlers(); registerOpenerHandlers(); + registerEditorTargetHandlers(); await createMainWindow(); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 6999fa9c2..fa2ec5e12 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -55,6 +55,15 @@ contextBridge.exposeInMainWorld("paseoDesktop", { opener: { openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url), }, + editor: { + listTargets: () => ipcRenderer.invoke("paseo:editor:listTargets"), + openTarget: (input: { + editorId: string; + path: string; + cwd?: string; + mode?: "open" | "reveal"; + }) => ipcRenderer.invoke("paseo:editor:openTarget", input), + }, webUtils: { getPathForFile: (file: File) => webUtils.getPathForFile(file), }, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 2e679363b..8af157fee 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -145,7 +145,6 @@ export const MutableDaemonConfigPatchSchema = z export type MutableDaemonConfig = z.infer; export type MutableDaemonConfigPatch = z.infer; -import type { LiteralUnion } from "./literal-union.js"; import type { AgentCapabilityFlags, AgentModelDefinition, @@ -1569,47 +1568,18 @@ export const WorkspaceSetupStatusRequestSchema = z.object({ requestId: z.string(), }); -// TODO(2026-07): 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, - label: z.string(), -}); - -export const ListAvailableEditorsRequestSchema = z.object({ +// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs. +export const LegacyListAvailableEditorsRequestSchema = z.object({ type: z.literal("list_available_editors_request"), requestId: z.string(), }); -export const OpenInEditorRequestSchema = z.object({ +export const LegacyOpenInEditorRequestSchema = z.object({ type: z.literal("open_in_editor_request"), path: z.string(), - editorId: EditorTargetIdSchema, + editorId: z.string().trim().min(1), + mode: z.enum(["open", "reveal"]).optional(), + cwd: z.string().optional(), requestId: z.string(), }); @@ -1924,8 +1894,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PaseoWorktreeArchiveRequestSchema, CreatePaseoWorktreeRequestSchema, WorkspaceSetupStatusRequestSchema, - ListAvailableEditorsRequestSchema, - OpenInEditorRequestSchema, + LegacyListAvailableEditorsRequestSchema, + LegacyOpenInEditorRequestSchema, OpenProjectRequestSchema, ArchiveWorkspaceRequestSchema, FileExplorerRequestSchema, @@ -2596,16 +2566,22 @@ export const StartWorkspaceScriptResponseMessageSchema = z.object({ }), }); -export const ListAvailableEditorsResponseMessageSchema = z.object({ +// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer parse daemon editor RPC responses. +export const LegacyListAvailableEditorsResponseMessageSchema = z.object({ type: z.literal("list_available_editors_response"), payload: z.object({ requestId: z.string(), - editors: z.array(EditorTargetDescriptorPayloadSchema), + editors: z.array( + z.object({ + id: z.string().trim().min(1), + label: z.string(), + }), + ), error: z.string().nullable(), }), }); -export const OpenInEditorResponseMessageSchema = z.object({ +export const LegacyOpenInEditorResponseMessageSchema = z.object({ type: z.literal("open_in_editor_response"), payload: z.object({ requestId: z.string(), @@ -3684,8 +3660,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchWorkspacesResponseMessageSchema, OpenProjectResponseMessageSchema, StartWorkspaceScriptResponseMessageSchema, - ListAvailableEditorsResponseMessageSchema, - OpenInEditorResponseMessageSchema, + LegacyListAvailableEditorsResponseMessageSchema, + LegacyOpenInEditorResponseMessageSchema, ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, @@ -3811,10 +3787,6 @@ export type WorkspaceDescriptorPayload = z.infer; export type WorkspaceScriptHealth = z.infer; export type WorkspaceScriptPayload = 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 FetchAgentHistoryResponseMessage = z.infer< typeof FetchAgentHistoryResponseMessageSchema @@ -3828,10 +3800,12 @@ export type OpenProjectResponseMessage = z.infer; -export type ListAvailableEditorsResponseMessage = z.infer< - typeof ListAvailableEditorsResponseMessageSchema +export type LegacyListAvailableEditorsResponseMessage = z.infer< + typeof LegacyListAvailableEditorsResponseMessageSchema +>; +export type LegacyOpenInEditorResponseMessage = z.infer< + typeof LegacyOpenInEditorResponseMessageSchema >; -export type OpenInEditorResponseMessage = z.infer; export type ArchiveWorkspaceResponseMessage = z.infer; export type FetchAgentResponseMessage = z.infer; export type FetchAgentTimelineResponseMessage = z.infer< @@ -4030,8 +4004,10 @@ export type PaseoWorktreeListResponse = z.infer; export type PaseoWorktreeArchiveResponse = z.infer; export type WorkspaceSetupStatusRequest = z.infer; -export type ListAvailableEditorsRequest = z.infer; -export type OpenInEditorRequest = z.infer; +export type LegacyListAvailableEditorsRequest = z.infer< + typeof LegacyListAvailableEditorsRequestSchema +>; +export type LegacyOpenInEditorRequest = z.infer; export type OpenProjectRequest = z.infer; export type ArchiveWorkspaceRequest = z.infer; export type FileExplorerRequest = z.infer; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index 3700d51f7..a74148325 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -241,62 +241,42 @@ describe("workspace message schemas", () => { expect(parsed.type).toBe("open_project_request"); }); - test("parses list_available_editors_request", () => { - const parsed = SessionInboundMessageSchema.parse({ + test("parses legacy editor RPC messages for compatibility", () => { + const listRequest = SessionInboundMessageSchema.parse({ type: "list_available_editors_request", requestId: "req-editors", }); - - expect(parsed.type).toBe("list_available_editors_request"); - }); - - test("parses open_in_editor_request with flexible editor ids", () => { - const knownEditor = SessionInboundMessageSchema.parse({ + const openRequest = 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", + requestId: "req-open-editor", editorId: "unknown-editor", path: "/tmp/repo", + mode: "reveal", + cwd: "/tmp", }); - - 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", - payload: { - requestId: "req-open-editor", - error: null, - }, - }); - - expect(parsed.type).toBe("open_in_editor_response"); - }); - - test("parses list_available_editors_response with unknown editor ids", () => { - const parsed = SessionOutboundMessageSchema.parse({ + const listResponse = SessionOutboundMessageSchema.parse({ type: "list_available_editors_response", payload: { requestId: "req-editors", - editors: [ - { id: "cursor", label: "Cursor" }, - { id: "unknown-editor", label: "Unknown Editor" }, - ], + editors: [{ id: "unknown-editor", label: "Unknown Editor" }], error: null, }, }); + const openResponse = SessionOutboundMessageSchema.parse({ + type: "open_in_editor_response", + payload: { + requestId: "req-open-editor", + error: "Editor opening moved to the desktop app", + }, + }); - expect(parsed.type).toBe("list_available_editors_response"); + expect(listRequest.type).toBe("list_available_editors_request"); + expect(openRequest.type).toBe("open_in_editor_request"); + expect(listResponse.type).toBe("list_available_editors_response"); + expect(openResponse.type).toBe("open_in_editor_response"); }); - test("rejects empty editor ids", () => { + test("rejects empty legacy editor ids", () => { const result = SessionInboundMessageSchema.safeParse({ type: "open_in_editor_request", requestId: "req-open-empty", diff --git a/packages/server/src/server/editor-targets.test.ts b/packages/server/src/server/editor-targets.test.ts deleted file mode 100644 index 11d8f4ead..000000000 --- a/packages/server/src/server/editor-targets.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { ChildProcess } from "node:child_process"; -import { describe, expect, it, vi } from "vitest"; -import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js"; - -describe("editor-targets", () => { - it("lists available editors in deterministic order", async () => { - const available = new Set(["code", "cursor", "explorer", "webstorm"]); - - const editors = await listAvailableEditorTargets({ - platform: "win32", - findExecutable: (command) => (available.has(command) ? command : null), - }); - - expect(editors).toEqual([ - { id: "cursor", label: "Cursor" }, - { id: "vscode", label: "VS Code" }, - { id: "webstorm", label: "WebStorm" }, - { id: "explorer", label: "Explorer" }, - ]); - }); - - it("returns Finder on macOS", async () => { - const editors = await listAvailableEditorTargets({ - platform: "darwin", - findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null), - }); - - expect(editors).toEqual([{ id: "finder", label: "Finder" }]); - }); - - it("returns the generic file manager target on Linux", async () => { - const editors = await listAvailableEditorTargets({ - platform: "linux", - findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null), - }); - - expect(editors).toEqual([{ id: "file-manager", label: "File Manager" }]); - }); - - it("launches editors as detached processes", async () => { - const unref = vi.fn(); - const once = vi.fn((event: string, handler: () => void) => { - if (event === "spawn") { - queueMicrotask(handler); - } - return child; - }); - const child = { once, unref }; - const spawn = vi.fn(() => child as unknown as ChildProcess); - - await openInEditorTarget( - { - editorId: "vscode", - path: "/tmp/repo", - }, - { - platform: "darwin", - existsSync: () => true, - findExecutable: (command) => (command === "code" ? "/usr/local/bin/code" : null), - spawn, - }, - ); - - expect(spawn).toHaveBeenCalledWith("/usr/local/bin/code", ["/tmp/repo"], { - detached: true, - env: expect.any(Object), - shell: false, - stdio: "ignore", - }); - expect(unref).toHaveBeenCalled(); - }); - - it("rejects relative paths", async () => { - await expect( - openInEditorTarget( - { - editorId: "cursor", - path: "repo", - }, - { - existsSync: () => true, - findExecutable: () => "/usr/local/bin/cursor", - }, - ), - ).rejects.toThrow("Editor target path must be an absolute local path"); - }); - - it("rejects platform-specific targets that are unavailable on this OS", async () => { - await expect( - openInEditorTarget( - { - editorId: "finder", - path: "/tmp/repo", - }, - { - platform: "linux", - existsSync: () => true, - findExecutable: () => "/usr/bin/open", - }, - ), - ).rejects.toThrow("Editor target unavailable: Finder"); - }); - - it("rejects unknown editor ids", async () => { - await expect( - openInEditorTarget( - { - editorId: "unknown-editor", - path: "/tmp/repo", - }, - { - existsSync: () => true, - findExecutable: () => null, - }, - ), - ).rejects.toThrow("Unknown editor target: unknown-editor"); - }); -}); diff --git a/packages/server/src/server/editor-targets.ts b/packages/server/src/server/editor-targets.ts deleted file mode 100644 index 16bfc4429..000000000 --- a/packages/server/src/server/editor-targets.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { ChildProcess } from "node:child_process"; -import { existsSync } from "node:fs"; -import { posix, win32 } from "node:path"; -import type { - EditorTargetDescriptorPayload, - EditorTargetId, - KnownEditorTargetId, -} from "@getpaseo/protocol/messages"; -import { createExternalProcessEnv } from "./paseo-env.js"; -import { findExecutable } from "../utils/executable.js"; -import { spawnProcess } from "../utils/spawn.js"; - -interface EditorTargetDefinition { - id: KnownEditorTargetId; - label: string; - command: string; - platforms?: readonly NodeJS.Platform[]; - excludedPlatforms?: readonly NodeJS.Platform[]; -} - -interface ListAvailableEditorTargetsDependencies { - platform?: NodeJS.Platform; - findExecutable?: (command: string) => string | null | Promise; -} - -type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & { - existsSync?: typeof existsSync; - spawn?: typeof spawnProcess; -}; - -const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [ - { id: "cursor", label: "Cursor", command: "cursor" }, - { id: "vscode", label: "VS Code", command: "code" }, - { id: "webstorm", label: "WebStorm", command: "webstorm" }, - { id: "zed", label: "Zed", command: "zed" }, - { id: "finder", label: "Finder", command: "open", platforms: ["darwin"] }, - { id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] }, - { - id: "file-manager", - label: "File Manager", - command: "xdg-open", - excludedPlatforms: ["darwin", "win32"], - }, -]; - -function isAbsolutePath(value: string): boolean { - return posix.isAbsolute(value) || win32.isAbsolute(value); -} - -function isTargetSupportedOnPlatform( - target: EditorTargetDefinition, - platform: NodeJS.Platform, -): boolean { - if (target.platforms && !target.platforms.includes(platform)) { - return false; - } - if (target.excludedPlatforms?.includes(platform)) { - return false; - } - return true; -} - -function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDefinition { - const target = EDITOR_TARGETS.find((entry) => entry.id === editorId); - if (!target) { - throw new Error(`Unknown editor target: ${editorId}`); - } - return target; -} - -export async function listAvailableEditorTargets( - dependencies: ListAvailableEditorTargetsDependencies = {}, -): Promise { - const platform = dependencies.platform ?? process.platform; - const findExecutableFn = dependencies.findExecutable ?? findExecutable; - - const supportedTargets = EDITOR_TARGETS.filter((target) => - isTargetSupportedOnPlatform(target, platform), - ); - const executables = await Promise.all( - supportedTargets.map((target) => findExecutableFn(target.command)), - ); - const results: EditorTargetDescriptorPayload[] = []; - for (let i = 0; i < supportedTargets.length; i += 1) { - if (!executables[i]) continue; - const target = supportedTargets[i]; - results.push({ - id: target.id, - label: target.label, - }); - } - return results; -} - -interface Launch { - command: string; - args: string[]; -} - -async function resolveEditorLaunch(input: { - editorId: EditorTargetId; - path: string; - platform: NodeJS.Platform; - findExecutableFn: (command: string) => string | null | Promise; -}): Promise { - const target = resolveEditorTargetDefinition(input.editorId); - if (!isTargetSupportedOnPlatform(target, input.platform)) { - throw new Error(`Editor target unavailable: ${target.label}`); - } - const executable = await input.findExecutableFn(target.command); - if (!executable) { - throw new Error(`Editor target unavailable: ${target.label}`); - } - - return { - command: executable, - args: [input.path], - }; -} - -export async function openInEditorTarget( - input: { - editorId: EditorTargetId; - path: string; - }, - dependencies: OpenInEditorTargetDependencies = {}, -): Promise { - const platform = dependencies.platform ?? process.platform; - const pathToOpen = input.path.trim(); - const existsSyncFn = dependencies.existsSync ?? existsSync; - const findExecutableFn = dependencies.findExecutable ?? findExecutable; - const spawnFn = dependencies.spawn ?? spawnProcess; - - if (!pathToOpen || !isAbsolutePath(pathToOpen)) { - throw new Error("Editor target path must be an absolute local path"); - } - if (!existsSyncFn(pathToOpen)) { - throw new Error(`Path does not exist: ${pathToOpen}`); - } - - const launch = await resolveEditorLaunch({ - editorId: input.editorId, - path: pathToOpen, - platform, - findExecutableFn, - }); - - await new Promise((resolve, reject) => { - let child: ChildProcess; - try { - child = spawnFn(launch.command, launch.args, { - detached: true, - env: createExternalProcessEnv(process.env), - shell: platform === "win32", - stdio: "ignore", - }); - } catch (error) { - reject(error); - return; - } - - child.once("error", reject); - child.once("spawn", () => { - child.unref(); - resolve(); - }); - }); -} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9c7e2b7eb..302d98ccf 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,5 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; -import { TTLCache } from "@isaacs/ttlcache"; -import pMemoize from "p-memoize"; import { realpathSync } from "node:fs"; import type { FSWatcher } from "node:fs"; import { basename, resolve, sep } from "path"; @@ -10,7 +8,6 @@ import { z } from "zod"; import type { ToolSet } from "ai"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import { - isLegacyEditorTargetId, serializeAgentStreamEvent, type AgentSnapshotPayload, type AgentAttachment, @@ -26,8 +23,6 @@ import { type SubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, - type EditorTargetDescriptorPayload, - type EditorTargetId, type ProjectPlacementPayload, type WorkspaceSetupSnapshot, type WorkspaceDescriptorPayload, @@ -47,7 +42,6 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js"; import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js"; import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js"; -import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js"; import { getPidLockInfo } from "./pid-lock.js"; import { generateLocalPairingOffer } from "./pairing-offer.js"; import { @@ -337,7 +331,6 @@ const LEGACY_MODE_ICONS = new Set([ "ShieldQuestionMark", ]); const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; -const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50"; function errorToFriendlyMessage(error: unknown): string { if (error instanceof Error) return error.message; @@ -418,10 +411,6 @@ function clientSupportsAllProviders(appVersion: string | null): boolean { return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS); } -function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean { - return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS); -} - type DeleteFencedAgentStorage = AgentStorage & { beginDelete(agentId: string): void; }; @@ -529,9 +518,6 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round( PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS, ); const AgentIdSchema = z.string().uuid(); -const AVAILABLE_EDITOR_TARGETS_CACHE_TTL_MS = 60_000; -const AVAILABLE_EDITOR_TARGETS_CACHE_KEY = "available"; - interface VoiceModeBaseConfig { systemPrompt?: string; } @@ -817,21 +803,6 @@ export class Session { private readonly terminalController: TerminalSessionController; private inflightRequests = 0; private peakInflightRequests = 0; - private readonly availableEditorTargetsCache = new TTLCache< - string, - EditorTargetDescriptorPayload[] - >({ - ttl: AVAILABLE_EDITOR_TARGETS_CACHE_TTL_MS, - max: 1, - checkAgeOnGet: true, - }); - private readonly getMemoizedAvailableEditorTargets = pMemoize( - async () => this.resolveAvailableEditorTargets(), - { - cache: this.availableEditorTargetsCache, - cacheKey: () => AVAILABLE_EDITOR_TARGETS_CACHE_KEY, - }, - ); private readonly checkoutDiffSubscriptions = new Map void>(); private readonly workspaceGitWatchTargets = new Map(); private readonly workspaceSetupSnapshots: Map; @@ -1446,15 +1417,6 @@ export class Session { return LEGACY_PROVIDER_IDS.has(provider); } - private filterEditorsForClient( - editors: EditorTargetDescriptorPayload[], - ): EditorTargetDescriptorPayload[] { - if (clientSupportsFlexibleEditorIds(this.appVersion)) { - return editors; - } - return editors.filter((editor) => isLegacyEditorTargetId(editor.id)); - } - private agentThinkingOptionMatchesFilter( agent: AgentSnapshotPayload, filter: AgentUpdatesFilter, @@ -2128,10 +2090,11 @@ export class Session { return this.handleCreatePaseoWorktreeRequest(msg); case "workspace_setup_status_request": return this.handleWorkspaceSetupStatusRequest(msg); + // COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs. case "list_available_editors_request": - return this.handleListAvailableEditorsRequest(msg); + return this.handleLegacyListAvailableEditorsRequest(msg); case "open_in_editor_request": - return this.handleOpenInEditorRequest(msg); + return this.handleLegacyOpenInEditorRequest(msg); case "open_project_request": return this.handleOpenProjectRequest(msg); case "archive_workspace_request": @@ -7166,18 +7129,6 @@ export class Session { }); } - async resolveAvailableEditorTargets(): Promise { - return listAvailableEditorTargets(); - } - - async getAvailableEditorTargets() { - return this.filterEditorsForClient(await this.getMemoizedAvailableEditorTargets()); - } - - async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise { - await openInEditorTarget(options); - } - private async handleStartWorkspaceScriptRequest( request: StartWorkspaceScriptRequest, ): Promise { @@ -7244,67 +7195,30 @@ export class Session { } } - private async handleListAvailableEditorsRequest( + // COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs. + private async handleLegacyListAvailableEditorsRequest( request: Extract, ): 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, - }, - }); - } + this.emit({ + type: "list_available_editors_response", + payload: { + requestId: request.requestId, + editors: [], + error: "Editor opening moved to the desktop app and is no longer supported by the daemon", + }, + }); } - private async handleOpenInEditorRequest( + private async handleLegacyOpenInEditorRequest( request: Extract, ): 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, - }, - }); - } + this.emit({ + type: "open_in_editor_response", + payload: { + requestId: request.requestId, + error: "Editor opening moved to the desktop app and is no longer supported by the daemon", + }, + }); } private async handleCreatePaseoWorktreeRequest( diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 84dc8d851..172a74245 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -5,11 +5,7 @@ import path from "node:path"; import { expect, test, vi } from "vitest"; import { z } from "zod"; import { Session } from "./session.js"; -import type { - AgentSnapshotPayload, - EditorTargetDescriptorPayload, - SessionOutboundMessage, -} from "@getpaseo/protocol/messages"; +import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage } from "./agent/agent-storage.js"; import type { @@ -123,10 +119,6 @@ interface SessionTestAccess { emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; emit(message: unknown): void; onMessage(message: unknown): void; - getAvailableEditorTargets(...args: unknown[]): Promise; - filterEditorsForClient(...args: unknown[]): unknown; - openEditorTarget(input: { editorId: string; path: string }): Promise; - resolveAvailableEditorTargets(...args: unknown[]): Promise; paseoHome: string; terminalManager: { killTerminal(id: string): unknown; @@ -3492,137 +3484,16 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root expect(response?.payload.workspace?.id).toBe(repoRoot); }); -test("list_available_editors_request returns available targets", async () => { +test("legacy editor RPC requests return daemon unsupported errors", async () => { const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests({ appVersion: "0.1.50" }); - - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; - session.getAvailableEditorTargets = async () => - session.filterEditorsForClient([ - { id: "cursor", label: "Cursor" }, - { id: "webstorm", label: "WebStorm" }, - { id: "finder", label: "Finder" }, - { id: "unknown-editor", label: "Unknown Editor" }, - ]); + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + }); await session.handleMessage({ type: "list_available_editors_request", requestId: "req-editors", }); - - const response = emitted.find((message) => message.type === "list_available_editors_response") as - | { payload: Record } - | undefined; - expect(response?.payload.error).toBeNull(); - expect(response?.payload.editors).toEqual([ - { id: "cursor", label: "Cursor" }, - { id: "webstorm", label: "WebStorm" }, - { id: "finder", label: "Finder" }, - { id: "unknown-editor", label: "Unknown Editor" }, - ]); -}); - -test("list_available_editors_request coalesces concurrent discovery", async () => { - const emitted: SessionOutboundMessage[] = []; - const session = asTestSession( - createSessionForWorkspaceTests({ - appVersion: "0.1.50", - onMessage: (message) => emitted.push(message), - }), - ); - let resolveDiscovery: (editors: EditorTargetDescriptorPayload[]) => void = () => {}; - let discoveryCalls = 0; - - vi.spyOn(session, "resolveAvailableEditorTargets").mockImplementation(async () => { - discoveryCalls += 1; - return await new Promise((resolve) => { - resolveDiscovery = resolve; - }); - }); - - const first = session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors-1", - }); - const second = session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors-2", - }); - - await Promise.resolve(); - expect(discoveryCalls).toBe(1); - - resolveDiscovery([{ id: "cursor", label: "Cursor" }]); - await Promise.all([first, second]); - - const responses = emitted.filter( - ( - message, - ): message is Extract => - message.type === "list_available_editors_response", - ); - expect(responses).toHaveLength(2); - expect(responses.map((response) => response.payload.requestId)).toEqual([ - "req-editors-1", - "req-editors-2", - ]); - expect(responses.map((response) => response.payload.editors)).toEqual([ - [{ id: "cursor", label: "Cursor" }], - [{ id: "cursor", label: "Cursor" }], - ]); - - await session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors-3", - }); - - expect(discoveryCalls).toBe(1); -}); - -test("list_available_editors_request filters unsupported ids for legacy clients", async () => { - const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests({ appVersion: "0.1.49" }); - - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; - session.getAvailableEditorTargets = async () => - session.filterEditorsForClient([ - { id: "cursor", label: "Cursor" }, - { id: "webstorm", label: "WebStorm" }, - { id: "unknown-editor", label: "Unknown Editor" }, - { id: "finder", label: "Finder" }, - ]); - - await session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors-legacy", - }); - - const response = emitted.find((message) => message.type === "list_available_editors_response") as - | { payload: Record } - | undefined; - expect(response?.payload.error).toBeNull(); - expect(response?.payload.editors).toEqual([ - { id: "cursor", label: "Cursor" }, - { id: "finder", label: "Finder" }, - ]); -}); - -test("open_in_editor_request launches the selected target", async () => { - const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests(); - const calls: Array<{ editorId: string; path: string }> = []; - - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; - session.openEditorTarget = async (input: { editorId: string; path: string }) => { - calls.push(input); - }; - await session.handleMessage({ type: "open_in_editor_request", requestId: "req-open-editor", @@ -3630,11 +3501,15 @@ test("open_in_editor_request launches the selected target", async () => { path: REPO_CWD, }); - expect(calls).toEqual([{ editorId: "vscode", path: REPO_CWD }]); - const response = emitted.find((message) => message.type === "open_in_editor_response") as - | { payload: Record } - | undefined; - expect(response?.payload.error).toBeNull(); + const listResponse = findByType(emitted, "list_available_editors_response"); + const openResponse = findByType(emitted, "open_in_editor_response"); + expect(listResponse?.payload.editors).toEqual([]); + expect(listResponse?.payload.error).toBe( + "Editor opening moved to the desktop app and is no longer supported by the daemon", + ); + expect(openResponse?.payload.error).toBe( + "Editor opening moved to the desktop app and is no longer supported by the daemon", + ); }); test("archive_workspace_request hides non-destructive workspace records", async () => {