feat(app): add open in editor toolbar action (#209)

* feat(app): add open in editor toolbar action

* fix(app): normalize editor icon asset permissions

* chore(website): drop unrelated generated route tree change
This commit is contained in:
Illia Panasenko
2026-04-07 10:25:25 +08:00
committed by GitHub
parent fd9dfb0cc8
commit 5cb424b2e6
17 changed files with 870 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,32 @@
import { Image, type ImageSourcePropType } from "react-native";
import type { EditorTargetId } from "@server/shared/messages";
interface EditorAppIconProps {
editorId: EditorTargetId;
size?: number;
color?: string;
}
/* eslint-disable @typescript-eslint/no-require-imports */
const EDITOR_APP_IMAGES: Record<EditorTargetId, ImageSourcePropType> = {
cursor: require("../../../assets/images/editor-apps/cursor.png"),
vscode: require("../../../assets/images/editor-apps/vscode.png"),
zed: require("../../../assets/images/editor-apps/zed.png"),
finder: require("../../../assets/images/editor-apps/finder.png"),
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
"file-manager": require("../../../assets/images/editor-apps/file-explorer.png"),
};
/* eslint-enable @typescript-eslint/no-require-imports */
export function EditorAppIcon({
editorId,
size = 16,
}: EditorAppIconProps) {
return (
<Image
source={EDITOR_APP_IMAGES[editorId]}
style={{ width: size, height: size }}
resizeMode="contain"
/>
);
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { resolvePreferredEditorId } from "./use-preferred-editor";
describe("resolvePreferredEditorId", () => {
it("keeps the stored editor when it is still available", () => {
expect(resolvePreferredEditorId(["cursor", "vscode"], "vscode")).toBe("vscode");
});
it("falls back to the first available editor when the stored one is missing", () => {
expect(resolvePreferredEditorId(["zed", "finder"], "cursor")).toBe("zed");
});
it("falls back when a platform-specific file manager target is unavailable", () => {
expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer");
});
it("returns null when no editors are available", () => {
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
});
});
@@ -0,0 +1,57 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EditorTargetIdSchema, type EditorTargetId } from "@server/shared/messages";
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
async function loadPreferredEditor(): Promise<EditorTargetId | null> {
const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY);
if (!stored) {
return null;
}
const parsed = EditorTargetIdSchema.safeParse(stored);
return parsed.success ? parsed.data : null;
}
export function resolvePreferredEditorId(
availableEditorIds: readonly EditorTargetId[],
storedEditorId: EditorTargetId | null | undefined,
): EditorTargetId | null {
if (
storedEditorId &&
availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId)
) {
return storedEditorId;
}
return availableEditorIds[0] ?? null;
}
export function usePreferredEditor() {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
queryKey: PREFERRED_EDITOR_QUERY_KEY,
queryFn: loadPreferredEditor,
staleTime: Infinity,
gcTime: Infinity,
});
const updatePreferredEditor = useCallback(
async (editorId: EditorTargetId | null) => {
queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId);
if (editorId) {
await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId);
return;
}
await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY);
},
[queryClient],
);
return {
preferredEditorId: data ?? null,
isLoading: isPending,
updatePreferredEditor,
};
}
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useMemo } from "react";
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type {
EditorTargetDescriptorPayload,
EditorTargetId,
} from "@server/shared/messages";
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
resolvePreferredEditorId,
usePreferredEditor,
} from "@/hooks/use-preferred-editor";
import { isAbsolutePath } from "@/utils/path";
interface WorkspaceOpenInEditorButtonProps {
serverId: string;
cwd: string;
}
export function WorkspaceOpenInEditorButton({
serverId,
cwd,
}: WorkspaceOpenInEditorButtonProps) {
const { theme } = useUnistyles();
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const shouldLoadEditors =
Platform.OS === "web" &&
Boolean(client && isConnected) &&
cwd.trim().length > 0 &&
isAbsolutePath(cwd);
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId],
enabled: shouldLoadEditors,
staleTime: 60_000,
retry: false,
queryFn: async () => {
if (!client) {
return [];
}
try {
const payload = await client.listAvailableEditors();
return payload.error ? [] : payload.editors;
} catch {
return [];
}
},
});
const availableEditors = availableEditorsQuery.data ?? [];
const availableEditorIds = useMemo(
() => availableEditors.map((editor: EditorTargetDescriptorPayload) => editor.id),
[availableEditors],
);
const effectivePreferredEditorId = useMemo(
() => resolvePreferredEditorId(availableEditorIds, preferredEditorId),
[availableEditorIds, preferredEditorId],
);
const primaryOption =
availableEditors.find(
(editor: EditorTargetDescriptorPayload) => editor.id === effectivePreferredEditorId,
) ?? null;
useEffect(() => {
if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) {
return;
}
void updatePreferredEditor(effectivePreferredEditorId).catch(() => undefined);
}, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]);
const openMutation = useMutation({
mutationFn: async (editorId: EditorTargetId) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.openInEditor(cwd, editorId);
if (payload.error) {
throw new Error(payload.error);
}
return editorId;
},
onError: (error: unknown) => {
toast.error(error instanceof Error ? error.message : "Failed to open in editor");
},
});
const handleOpenEditor = useCallback(
(editorId: EditorTargetId) => {
void updatePreferredEditor(editorId).catch(() => undefined);
openMutation.mutate(editorId);
},
[openMutation, updatePreferredEditor],
);
if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) {
return null;
}
return (
<View style={styles.row}>
<View style={styles.splitButton}>
<Pressable
testID="workspace-open-in-editor-primary"
style={({ hovered, pressed }) => [
styles.splitButtonPrimary,
(hovered || pressed) && styles.splitButtonPrimaryHovered,
openMutation.isPending && styles.splitButtonPrimaryDisabled,
]}
onPress={() => handleOpenEditor(primaryOption.id)}
disabled={openMutation.isPending}
accessibilityRole="button"
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
>
{openMutation.isPending ? (
<ActivityIndicator
size="small"
color={theme.colors.foreground}
style={styles.splitButtonSpinnerOnly}
/>
) : (
<View style={styles.splitButtonContent}>
<EditorAppIcon
editorId={primaryOption.id}
size={16}
color={theme.colors.foregroundMuted}
/>
<Text style={styles.splitButtonText}>Open</Text>
</View>
)}
</Pressable>
{availableEditors.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-open-in-editor-caret"
style={({ hovered, pressed, open }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
]}
accessibilityRole="button"
accessibilityLabel="Choose editor"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
minWidth={148}
maxWidth={176}
testID="workspace-open-in-editor-menu"
>
{availableEditors.map((editor: EditorTargetDescriptorPayload) => (
<DropdownMenuItem
key={editor.id}
testID={`workspace-open-in-editor-item-${editor.id}`}
leading={
<EditorAppIcon
editorId={editor.id}
size={16}
color={theme.colors.foregroundMuted}
/>
}
trailing={
editor.id === effectivePreferredEditorId
? <Check size={16} color={theme.colors.foregroundMuted} />
: undefined
}
onSelect={() => handleOpenEditor(editor.id)}
>
{editor.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
splitButton: {
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
splitButtonPrimary: {
paddingLeft: theme.spacing[3],
paddingRight: 10,
paddingVertical: theme.spacing[1],
justifyContent: "center",
position: "relative",
},
splitButtonPrimaryHovered: {
backgroundColor: theme.colors.surface2,
},
splitButtonPrimaryDisabled: {
opacity: 0.6,
},
splitButtonText: {
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
splitButtonContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
},
splitButtonSpinnerOnly: {
transform: [{ scale: 0.8 }],
},
splitButtonCaret: {
width: 28,
alignItems: "center",
justifyContent: "center",
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
splitButtonCaretHovered: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -48,6 +48,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { SplitContainer } from "@/components/split-container";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -2045,6 +2046,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
}
right={
<View style={styles.headerRight}>
{!isMobile ? (
<WorkspaceOpenInEditorButton
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
/>
) : null}
{!isMobile && isGitCheckout ? (
<>
<WorkspaceGitActions
@@ -34,6 +34,8 @@ import type {
PaseoWorktreeListResponse,
PaseoWorktreeArchiveResponse,
ProjectIconResponse,
ListAvailableEditorsResponseMessage,
OpenInEditorResponseMessage,
OpenProjectResponseMessage,
ArchiveWorkspaceResponseMessage,
ListCommandsResponse,
@@ -54,6 +56,7 @@ import type {
TerminalInput,
SessionInboundMessage,
SessionOutboundMessage,
EditorTargetId,
} from "../shared/messages.js";
import type {
AgentPermissionRequest,
@@ -472,8 +475,11 @@ export type InspectScheduleOptions = {
id: string;
requestId?: string;
};
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number];
export type FetchAgentResult = {
agent: AgentSnapshotPayload;
@@ -1318,6 +1324,34 @@ 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,
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from "vitest";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
describe("editor-targets", () => {
it("lists available editors in deterministic order", () => {
const available = new Set(["code", "cursor", "explorer"]);
const editors = listAvailableEditorTargets({
platform: "win32",
findExecutable: (command) => (available.has(command) ? command : null),
});
expect(editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "vscode", label: "VS Code" },
{ id: "explorer", label: "Explorer" },
]);
});
it("returns Finder on macOS", () => {
const editors = listAvailableEditorTargets({
platform: "darwin",
findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null),
});
expect(editors).toEqual([{ id: "finder", label: "Finder" }]);
});
it("returns the generic file manager target on Linux", () => {
const editors = listAvailableEditorTargets({
platform: "linux",
findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null),
});
expect(editors).toEqual([{ id: "file-manager", label: "File Manager" }]);
});
it("launches editors as detached processes", async () => {
const unref = vi.fn();
const once = vi.fn((event: string, handler: () => void) => {
if (event === "spawn") {
queueMicrotask(handler);
}
return child;
});
const child = { once, unref };
const spawn = vi.fn(() => child as any);
await openInEditorTarget(
{
editorId: "vscode",
path: "/tmp/repo",
},
{
platform: "darwin",
existsSync: () => true,
findExecutable: (command) => (command === "code" ? "/usr/local/bin/code" : null),
spawn,
},
);
expect(spawn).toHaveBeenCalledWith("/usr/local/bin/code", ["/tmp/repo"], {
detached: true,
shell: false,
stdio: "ignore",
});
expect(unref).toHaveBeenCalled();
});
it("rejects relative paths", async () => {
await expect(
openInEditorTarget(
{
editorId: "cursor",
path: "repo",
},
{
existsSync: () => true,
findExecutable: () => "/usr/local/bin/cursor",
},
),
).rejects.toThrow("Editor target path must be an absolute local path");
});
it("rejects platform-specific targets that are unavailable on this OS", async () => {
await expect(
openInEditorTarget(
{
editorId: "finder",
path: "/tmp/repo",
},
{
platform: "linux",
existsSync: () => true,
findExecutable: () => "/usr/bin/open",
},
),
).rejects.toThrow("Editor target unavailable: Finder");
});
});
@@ -0,0 +1,168 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { posix, win32 } from "node:path";
import type { EditorTargetDescriptorPayload, EditorTargetId } from "../shared/messages.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../utils/executable.js";
type EditorTargetDefinition = {
id: EditorTargetId;
label: string;
command: string;
platforms?: readonly NodeJS.Platform[];
excludedPlatforms?: readonly NodeJS.Platform[];
};
type ListAvailableEditorTargetsDependencies = {
platform?: NodeJS.Platform;
findExecutable?: (command: string) => string | null;
};
type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
existsSync?: typeof existsSync;
spawn?: typeof spawn;
};
const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
{ id: "cursor", label: "Cursor", command: "cursor" },
{ id: "vscode", label: "VS Code", command: "code" },
{ id: "zed", label: "Zed", command: "zed" },
{ id: "finder", label: "Finder", command: "open", platforms: ["darwin"] },
{ id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] },
{
id: "file-manager",
label: "File Manager",
command: "xdg-open",
excludedPlatforms: ["darwin", "win32"],
},
];
function isAbsolutePath(value: string): boolean {
return posix.isAbsolute(value) || win32.isAbsolute(value);
}
function isTargetSupportedOnPlatform(
target: EditorTargetDefinition,
platform: NodeJS.Platform,
): boolean {
if (target.platforms && !target.platforms.includes(platform)) {
return false;
}
if (target.excludedPlatforms?.includes(platform)) {
return false;
}
return true;
}
function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDefinition {
const target = EDITOR_TARGETS.find((entry) => entry.id === editorId);
if (!target) {
throw new Error(`Unknown editor target: ${editorId}`);
}
return target;
}
export function listAvailableEditorTargets(
dependencies: ListAvailableEditorTargetsDependencies = {},
): EditorTargetDescriptorPayload[] {
const platform = dependencies.platform ?? process.platform;
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
return EDITOR_TARGETS.flatMap((target) => {
if (!isTargetSupportedOnPlatform(target, platform)) {
return [];
}
const executable = findExecutableFn(target.command);
if (!executable) {
return [];
}
return [
{
id: target.id,
label: target.label,
},
];
});
}
type Launch = {
command: string;
args: string[];
};
function resolveEditorLaunch(input: {
editorId: EditorTargetId;
path: string;
platform: NodeJS.Platform;
findExecutableFn: typeof findExecutable;
}): Launch {
const target = resolveEditorTargetDefinition(input.editorId);
if (!isTargetSupportedOnPlatform(target, input.platform)) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
const executable = input.findExecutableFn(target.command);
if (!executable) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
return {
command: executable,
args: [input.path],
};
}
export async function openInEditorTarget(
input: {
editorId: EditorTargetId;
path: string;
},
dependencies: OpenInEditorTargetDependencies = {},
): Promise<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 ?? spawn;
if (!pathToOpen || !isAbsolutePath(pathToOpen)) {
throw new Error("Editor target path must be an absolute local path");
}
if (!existsSyncFn(pathToOpen)) {
throw new Error(`Path does not exist: ${pathToOpen}`);
}
const launch = resolveEditorLaunch({
editorId: input.editorId,
path: pathToOpen,
platform,
findExecutableFn,
});
const command = platform === "win32" ? quoteWindowsCommand(launch.command) : launch.command;
const args =
platform === "win32" ? launch.args.map((argument) => quoteWindowsArgument(argument)) : launch.args;
await new Promise<void>((resolve, reject) => {
let child: ChildProcess;
try {
child = spawnFn(command, args, {
detached: true,
shell: platform === "win32",
stdio: "ignore",
});
} catch (error) {
reject(error);
return;
}
child.once("error", reject);
child.once("spawn", () => {
child.unref();
resolve();
});
});
}
+81
View File
@@ -28,6 +28,7 @@ import {
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type EditorTargetId,
type ProjectPlacementPayload,
type WorkspaceDescriptorPayload,
type WorkspaceStateBucket,
@@ -47,6 +48,7 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
import {
DictationStreamManager,
type DictationStreamOutboundMessage,
@@ -1742,6 +1744,14 @@ export class Session {
await this.handleCreatePaseoWorktreeRequest(msg);
break;
case "list_available_editors_request":
await this.handleListAvailableEditorsRequest(msg);
break;
case "open_in_editor_request":
await this.handleOpenInEditorRequest(msg);
break;
case "open_project_request":
await this.handleOpenProjectRequest(msg);
break;
@@ -5981,6 +5991,77 @@ export class Session {
}
}
async getAvailableEditorTargets() {
return listAvailableEditorTargets();
}
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
await openInEditorTarget(options);
}
private async handleListAvailableEditorsRequest(
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,
},
});
}
}
private async handleOpenInEditorRequest(
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,
},
});
}
}
private async handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
@@ -1223,6 +1223,55 @@ describe("workspace aggregation", () => {
expect(response?.payload.workspace?.id).toBe(repoRoot);
});
test("list_available_editors_request returns available targets", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any;
session.emit = (message: any) => emitted.push(message);
session.getAvailableEditorTargets = async () => [
{ id: "cursor", label: "Cursor" },
{ id: "finder", label: "Finder" },
];
await session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors",
});
const response = emitted.find(
(message) => message.type === "list_available_editors_response",
) as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "finder", label: "Finder" },
]);
});
test("open_in_editor_request launches the selected target", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any;
const calls: Array<{ editorId: string; path: string }> = [];
session.emit = (message: any) => emitted.push(message);
session.openEditorTarget = async (input: { editorId: string; path: string }) => {
calls.push(input);
};
await session.handleMessage({
type: "open_in_editor_request",
requestId: "req-open-editor",
editorId: "vscode",
path: "/tmp/repo",
});
expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]);
const response = emitted.find(
(message) => message.type === "open_in_editor_response",
) as any;
expect(response?.payload.error).toBeNull();
});
test("archive_workspace_request hides non-destructive workspace records", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any;
+55
View File
@@ -1084,6 +1084,32 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
requestId: z.string(),
});
export const EditorTargetIdSchema = z.enum([
"cursor",
"vscode",
"zed",
"finder",
"explorer",
"file-manager",
]);
export const EditorTargetDescriptorPayloadSchema = z.object({
id: EditorTargetIdSchema,
label: z.string(),
});
export const ListAvailableEditorsRequestSchema = z.object({
type: z.literal("list_available_editors_request"),
requestId: z.string(),
});
export const OpenInEditorRequestSchema = z.object({
type: z.literal("open_in_editor_request"),
path: z.string(),
editorId: EditorTargetIdSchema,
requestId: z.string(),
});
export const OpenProjectRequestSchema = z.object({
type: z.literal("open_project_request"),
cwd: z.string(),
@@ -1341,6 +1367,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeListRequestSchema,
PaseoWorktreeArchiveRequestSchema,
CreatePaseoWorktreeRequestSchema,
ListAvailableEditorsRequestSchema,
OpenInEditorRequestSchema,
OpenProjectRequestSchema,
ArchiveWorkspaceRequestSchema,
FileExplorerRequestSchema,
@@ -1828,6 +1856,23 @@ export const OpenProjectResponseMessageSchema = z.object({
}),
});
export const ListAvailableEditorsResponseMessageSchema = z.object({
type: z.literal("list_available_editors_response"),
payload: z.object({
requestId: z.string(),
editors: z.array(EditorTargetDescriptorPayloadSchema),
error: z.string().nullable(),
}),
});
export const OpenInEditorResponseMessageSchema = z.object({
type: z.literal("open_in_editor_response"),
payload: z.object({
requestId: z.string(),
error: z.string().nullable(),
}),
});
export const ArchiveWorkspaceResponseMessageSchema = z.object({
type: z.literal("archive_workspace_response"),
payload: z.object({
@@ -2475,6 +2520,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchAgentsResponseMessageSchema,
FetchWorkspacesResponseMessageSchema,
OpenProjectResponseMessageSchema,
ListAvailableEditorsResponseMessageSchema,
OpenInEditorResponseMessageSchema,
ArchiveWorkspaceResponseMessageSchema,
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
@@ -2567,9 +2614,15 @@ export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePaylo
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
export type EditorTargetId = z.infer<typeof EditorTargetIdSchema>;
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
export type ListAvailableEditorsResponseMessage = z.infer<
typeof ListAvailableEditorsResponseMessageSchema
>;
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<
@@ -2717,6 +2770,8 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type ListAvailableEditorsRequest = z.infer<typeof ListAvailableEditorsRequestSchema>;
export type OpenInEditorRequest = z.infer<typeof OpenInEditorRequestSchema>;
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
@@ -29,6 +29,27 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("open_project_request");
});
test("parses list_available_editors_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "list_available_editors_request",
requestId: "req-editors",
});
expect(parsed.type).toBe("list_available_editors_request");
});
test("parses open_in_editor_response", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "open_in_editor_response",
payload: {
requestId: "req-open-editor",
error: null,
},
});
expect(parsed.type).toBe("open_in_editor_response");
});
test("rejects invalid workspace update payload", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "workspace_update",