Open workspace files in desktop targets

This commit is contained in:
Mohamed Boudra
2026-06-03 18:34:24 +08:00
committed by GitHub
parent 45f412456c
commit 3681fd132b
30 changed files with 1830 additions and 822 deletions

View File

@@ -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<void>;
}
}
@@ -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<void> {
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<string, unknown>) => Promise<unknown>;
dialog: {
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
};
getPendingOpenProject: () => Promise<string | null>;
events: { on: () => Promise<() => void> };
editor?: {
listTargets: () => Promise<DesktopEditorTargetConfig[]>;
openTarget: (input: DesktopEditorOpenRecord) => Promise<void>;
};
} = {
platform: "darwin",
invoke: async (command: string, args?: Record<string, unknown>) => {
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);
}

View File

@@ -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<void>
async function expectEditorOpened(input: {
recordPath: string;
command: string;
workspacePath: string;
editorId: string;
path: string;
afterCount: number;
}): Promise<void> {
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,
});
});

View File

@@ -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<KnownEditorTargetId, ImageSourcePropType> = {
const EDITOR_APP_IMAGES: Record<string, ImageSourcePropType> = {
cursor: require("../../../assets/images/editor-apps/cursor.png"),
vscode: require("../../../assets/images/editor-apps/vscode.png"),
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
@@ -25,15 +21,16 @@ const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
};
/* 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 <SquareTerminal size={size} color={color} />;
}
return <Image source={EDITOR_APP_IMAGES[editorId]} style={imageStyle} resizeMode="contain" />;
return <Image source={source} style={imageStyle} resizeMode="contain" />;
}

View File

@@ -51,6 +51,24 @@ export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
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<DesktopEditorTargetDescriptor[]>;
openTarget?: (input: DesktopEditorOpenTargetInput) => Promise<void>;
}
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;

View File

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

View File

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

View File

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

View File

@@ -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<EditorTargetId | null> {
if (!stored) {
return null;
}
const parsed = EditorTargetIdSchema.safeParse(stored);
return parsed.success ? parsed.data : null;
return stored.trim() || null;
}
export function resolvePreferredEditorId(

View File

@@ -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> | 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<EditorTargetDescriptorPayload[]>({
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<OpenTarget[]>(
const targets = useMemo<OpenTarget[]>(
() =>
availableEditors.map((editor) => ({
id: editor.id,
label: editor.label,
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
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<OpenTarget | null>(() => {
if (!checkoutStatus?.isGit) {
return null;
}
const url = buildGitHubBranchTreeUrl({
remoteUrl: checkoutStatus.remoteUrl,
branch: checkoutStatus.currentBranch,
});
if (!url) {
return null;
}
return {
id: "github",
label: "GitHub",
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
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: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
onOpen: () => openExternalUrl(target.url),
};
}
return {
id: target.id,
label: target.label,
icon: <ThemedEditorAppIcon editorId={target.id} size={16} uniProps={mutedColorMapping} />,
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 ? (
<ThemedActivityIndicator

View File

@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
describe("filterTargetsForDaemonLocation", () => {
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 },
]);
});
});

View File

@@ -1,13 +0,0 @@
export interface WorkspaceOpenTargetAvailability {
requiresLocalDaemon: boolean;
}
export function filterTargetsForDaemonLocation<Target extends WorkspaceOpenTargetAvailability>(
targets: readonly Target[],
input: { isLocalDaemon: boolean },
): Target[] {
if (input.isLocalDaemon) {
return [...targets];
}
return targets.filter((target) => !target.requiresLocalDaemon);
}

View File

@@ -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<WorkspaceFileLocation | null>(
() =>
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 ? (
<WorkspaceOpenInEditorButton
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
cwd={workspaceDirectory}
activeFile={activeFileLocation}
hideLabels={showCompactButtonLabels}
/>
) : null}
@@ -3193,6 +3232,8 @@ function WorkspaceScreenContent({
workspaceDescriptor,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
activeFileLocation,
liveTerminalIds,
handleScriptTerminalStarted,
handleViewScriptTerminal,

View File

@@ -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<DesktopEditorBridge["listTargets"]>;
openTarget: NonNullable<DesktopEditorBridge["openTarget"]>;
}
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<DesktopOpenTarget[]> {
const bridge = getDesktopEditorBridge();
if (!bridge) {
return [];
}
return await bridge.listTargets();
}
export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise<void> {
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,
};
}

View File

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

View File

@@ -0,0 +1,15 @@
export type EditorTargetId = string;
const KNOWN_EDITOR_TARGET_IDS: ReadonlySet<string> = new Set([
"cursor",
"vscode",
"webstorm",
"zed",
"finder",
"explorer",
"file-manager",
]);
export function isKnownEditorTargetId(editorId: EditorTargetId): boolean {
return KNOWN_EDITOR_TARGET_IDS.has(editorId);
}

View File

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

View File

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

View File

@@ -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"]);
});
});

View File

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

View File

@@ -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<ListAvailableEditorsPayload> {
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<OpenInEditorPayload> {
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,

View File

@@ -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<string, (event: unknown, ...args: unknown[]) => 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"]);
});
});

View File

@@ -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<void> {
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<void>((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);
});
}

View File

@@ -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<void> {
registerDialogHandlers();
registerNotificationHandlers();
registerOpenerHandlers();
registerEditorTargetHandlers();
await createMainWindow();

View File

@@ -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),
},

View File

@@ -145,7 +145,6 @@ export const MutableDaemonConfigPatchSchema = z
export type MutableDaemonConfig = z.infer<typeof MutableDaemonConfigSchema>;
export type MutableDaemonConfigPatch = z.infer<typeof MutableDaemonConfigPatchSchema>;
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<string>(KNOWN_EDITOR_TARGET_IDS);
const LEGACY_EDITOR_TARGET_ID_SET = new Set<string>(LEGACY_EDITOR_TARGET_IDS);
export function isKnownEditorTargetId(value: string): value is KnownEditorTargetId {
return KNOWN_EDITOR_TARGET_ID_SET.has(value);
}
export function isLegacyEditorTargetId(value: string): value is LegacyEditorTargetId {
return LEGACY_EDITOR_TARGET_ID_SET.has(value);
}
export const EditorTargetDescriptorPayloadSchema = z.object({
id: EditorTargetIdSchema,
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<typeof WorkspaceDescriptorPaylo
export type WorkspaceScriptLifecycle = z.infer<typeof WorkspaceScriptLifecycleSchema>;
export type WorkspaceScriptHealth = z.infer<typeof WorkspaceScriptHealthSchema>;
export type WorkspaceScriptPayload = z.infer<typeof WorkspaceScriptPayloadSchema>;
export type KnownEditorTargetId = z.infer<typeof KnownEditorTargetIdSchema>;
export type LegacyEditorTargetId = z.infer<typeof LegacyEditorTargetIdSchema>;
export type EditorTargetId = LiteralUnion<KnownEditorTargetId, string>;
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchAgentHistoryResponseMessage = z.infer<
typeof FetchAgentHistoryResponseMessageSchema
@@ -3828,10 +3800,12 @@ export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessa
export type StartWorkspaceScriptResponseMessage = z.infer<
typeof StartWorkspaceScriptResponseMessageSchema
>;
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<typeof OpenInEditorResponseMessageSchema>;
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
export type FetchAgentTimelineResponseMessage = z.infer<
@@ -4030,8 +4004,10 @@ export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponse
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
export type ListAvailableEditorsRequest = z.infer<typeof ListAvailableEditorsRequestSchema>;
export type OpenInEditorRequest = z.infer<typeof OpenInEditorRequestSchema>;
export type LegacyListAvailableEditorsRequest = z.infer<
typeof LegacyListAvailableEditorsRequestSchema
>;
export type LegacyOpenInEditorRequest = z.infer<typeof LegacyOpenInEditorRequestSchema>;
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;

View File

@@ -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",

View File

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

View File

@@ -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<string | null>;
}
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<EditorTargetDescriptorPayload[]> {
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<string | null>;
}): Promise<Launch> {
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<void> {
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<void>((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();
});
});
}

View File

@@ -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<string>([
"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<string, () => void>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
@@ -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<EditorTargetDescriptorPayload[]> {
return listAvailableEditorTargets();
}
async getAvailableEditorTargets() {
return this.filterEditorsForClient(await this.getMemoizedAvailableEditorTargets());
}
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
await openInEditorTarget(options);
}
private async handleStartWorkspaceScriptRequest(
request: StartWorkspaceScriptRequest,
): Promise<void> {
@@ -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<SessionInboundMessage, { type: "list_available_editors_request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "open_in_editor_request" }>,
): Promise<void> {
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(

View File

@@ -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<unknown>;
emit(message: unknown): void;
onMessage(message: unknown): void;
getAvailableEditorTargets(...args: unknown[]): Promise<unknown>;
filterEditorsForClient(...args: unknown[]): unknown;
openEditorTarget(input: { editorId: string; path: string }): Promise<unknown>;
resolveAvailableEditorTargets(...args: unknown[]): Promise<unknown>;
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<string, unknown> }
| 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<EditorTargetDescriptorPayload[]>((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<SessionOutboundMessage, { type: "list_available_editors_response" }> =>
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<string, unknown> }
| 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<string, unknown> }
| 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 () => {