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:
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 { SplitContainer } from "@/components/split-container";
|
||||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||||
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
|
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 { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
@@ -2045,6 +2046,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<View style={styles.headerRight}>
|
<View style={styles.headerRight}>
|
||||||
|
{!isMobile ? (
|
||||||
|
<WorkspaceOpenInEditorButton
|
||||||
|
serverId={normalizedServerId}
|
||||||
|
cwd={normalizedWorkspaceId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{!isMobile && isGitCheckout ? (
|
{!isMobile && isGitCheckout ? (
|
||||||
<>
|
<>
|
||||||
<WorkspaceGitActions
|
<WorkspaceGitActions
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import type {
|
|||||||
PaseoWorktreeListResponse,
|
PaseoWorktreeListResponse,
|
||||||
PaseoWorktreeArchiveResponse,
|
PaseoWorktreeArchiveResponse,
|
||||||
ProjectIconResponse,
|
ProjectIconResponse,
|
||||||
|
ListAvailableEditorsResponseMessage,
|
||||||
|
OpenInEditorResponseMessage,
|
||||||
OpenProjectResponseMessage,
|
OpenProjectResponseMessage,
|
||||||
ArchiveWorkspaceResponseMessage,
|
ArchiveWorkspaceResponseMessage,
|
||||||
ListCommandsResponse,
|
ListCommandsResponse,
|
||||||
@@ -54,6 +56,7 @@ import type {
|
|||||||
TerminalInput,
|
TerminalInput,
|
||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
|
EditorTargetId,
|
||||||
} from "../shared/messages.js";
|
} from "../shared/messages.js";
|
||||||
import type {
|
import type {
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
@@ -472,8 +475,11 @@ export type InspectScheduleOptions = {
|
|||||||
id: string;
|
id: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
};
|
};
|
||||||
|
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
|
||||||
|
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
|
||||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||||
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
||||||
|
export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number];
|
||||||
|
|
||||||
export type FetchAgentResult = {
|
export type FetchAgentResult = {
|
||||||
agent: AgentSnapshotPayload;
|
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(
|
async archiveWorkspace(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
requestId?: 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
type SubscribeCheckoutDiffRequest,
|
type SubscribeCheckoutDiffRequest,
|
||||||
type UnsubscribeCheckoutDiffRequest,
|
type UnsubscribeCheckoutDiffRequest,
|
||||||
type DirectorySuggestionsRequest,
|
type DirectorySuggestionsRequest,
|
||||||
|
type EditorTargetId,
|
||||||
type ProjectPlacementPayload,
|
type ProjectPlacementPayload,
|
||||||
type WorkspaceDescriptorPayload,
|
type WorkspaceDescriptorPayload,
|
||||||
type WorkspaceStateBucket,
|
type WorkspaceStateBucket,
|
||||||
@@ -47,6 +48,7 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech
|
|||||||
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
||||||
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
|
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
|
||||||
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
|
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
|
||||||
|
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
|
||||||
import {
|
import {
|
||||||
DictationStreamManager,
|
DictationStreamManager,
|
||||||
type DictationStreamOutboundMessage,
|
type DictationStreamOutboundMessage,
|
||||||
@@ -1742,6 +1744,14 @@ export class Session {
|
|||||||
await this.handleCreatePaseoWorktreeRequest(msg);
|
await this.handleCreatePaseoWorktreeRequest(msg);
|
||||||
break;
|
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":
|
case "open_project_request":
|
||||||
await this.handleOpenProjectRequest(msg);
|
await this.handleOpenProjectRequest(msg);
|
||||||
break;
|
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(
|
private async handleCreatePaseoWorktreeRequest(
|
||||||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -1223,6 +1223,55 @@ describe("workspace aggregation", () => {
|
|||||||
expect(response?.payload.workspace?.id).toBe(repoRoot);
|
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 () => {
|
test("archive_workspace_request hides non-destructive workspace records", async () => {
|
||||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||||
const session = createSessionForWorkspaceTests() as any;
|
const session = createSessionForWorkspaceTests() as any;
|
||||||
|
|||||||
@@ -1084,6 +1084,32 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
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({
|
export const OpenProjectRequestSchema = z.object({
|
||||||
type: z.literal("open_project_request"),
|
type: z.literal("open_project_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -1341,6 +1367,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
PaseoWorktreeListRequestSchema,
|
PaseoWorktreeListRequestSchema,
|
||||||
PaseoWorktreeArchiveRequestSchema,
|
PaseoWorktreeArchiveRequestSchema,
|
||||||
CreatePaseoWorktreeRequestSchema,
|
CreatePaseoWorktreeRequestSchema,
|
||||||
|
ListAvailableEditorsRequestSchema,
|
||||||
|
OpenInEditorRequestSchema,
|
||||||
OpenProjectRequestSchema,
|
OpenProjectRequestSchema,
|
||||||
ArchiveWorkspaceRequestSchema,
|
ArchiveWorkspaceRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
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({
|
export const ArchiveWorkspaceResponseMessageSchema = z.object({
|
||||||
type: z.literal("archive_workspace_response"),
|
type: z.literal("archive_workspace_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -2475,6 +2520,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
FetchAgentsResponseMessageSchema,
|
FetchAgentsResponseMessageSchema,
|
||||||
FetchWorkspacesResponseMessageSchema,
|
FetchWorkspacesResponseMessageSchema,
|
||||||
OpenProjectResponseMessageSchema,
|
OpenProjectResponseMessageSchema,
|
||||||
|
ListAvailableEditorsResponseMessageSchema,
|
||||||
|
OpenInEditorResponseMessageSchema,
|
||||||
ArchiveWorkspaceResponseMessageSchema,
|
ArchiveWorkspaceResponseMessageSchema,
|
||||||
FetchAgentResponseMessageSchema,
|
FetchAgentResponseMessageSchema,
|
||||||
FetchAgentTimelineResponseMessageSchema,
|
FetchAgentTimelineResponseMessageSchema,
|
||||||
@@ -2567,9 +2614,15 @@ export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePaylo
|
|||||||
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
|
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
|
||||||
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
|
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
|
||||||
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
|
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 FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
|
||||||
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
||||||
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
|
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 ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
|
||||||
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
|
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
|
||||||
export type FetchAgentTimelineResponseMessage = z.infer<
|
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 PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
|
||||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||||
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
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 OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
|
||||||
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
||||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||||
|
|||||||
@@ -29,6 +29,27 @@ describe("workspace message schemas", () => {
|
|||||||
expect(parsed.type).toBe("open_project_request");
|
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", () => {
|
test("rejects invalid workspace update payload", () => {
|
||||||
const result = SessionOutboundMessageSchema.safeParse({
|
const result = SessionOutboundMessageSchema.safeParse({
|
||||||
type: "workspace_update",
|
type: "workspace_update",
|
||||||
|
|||||||
Reference in New Issue
Block a user