Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
import { appendFile } from "node:fs/promises";
|
||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { openSettings } from "./app";
|
import { openSettings } from "./app";
|
||||||
import { getE2EDaemonPort } from "./daemon-port";
|
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.
|
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||||
*/
|
*/
|
||||||
confirmShouldAccept?: boolean;
|
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 {
|
export interface ConfirmDialogCall {
|
||||||
@@ -74,6 +90,7 @@ export interface ConfirmDialogCall {
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||||
|
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +104,15 @@ declare global {
|
|||||||
* can assert dialog copy without depending on window.confirm concatenation.
|
* can assert dialog copy without depending on window.confirm concatenation.
|
||||||
*/
|
*/
|
||||||
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
|
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) => {
|
await page.addInitScript((cfg) => {
|
||||||
// Mutable state shared across IPC calls within this page
|
// Mutable state shared across IPC calls within this page
|
||||||
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
|
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",
|
platform: "darwin",
|
||||||
invoke: async (command: string, args?: Record<string, unknown>) => {
|
invoke: async (command: string, args?: Record<string, unknown>) => {
|
||||||
if (command === "check_app_update") {
|
if (command === "check_app_update") {
|
||||||
@@ -202,6 +240,17 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
getPendingOpenProject: async () => null,
|
getPendingOpenProject: async () => null,
|
||||||
events: { on: async () => () => undefined },
|
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);
|
}, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function waitForQuestionPrompt(page: Page, timeout = 30_000): Promise<void> {
|
||||||
|
await expect(page.getByTestId("question-form-card").first()).toBeVisible({ timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectCurrentQuestion(
|
||||||
|
page: Page,
|
||||||
|
input: { index: number; total: number; question: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const card = page.getByTestId("question-form-card").first();
|
||||||
|
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
|
||||||
|
await expect(
|
||||||
|
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
|
||||||
|
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||||
|
await page
|
||||||
|
.getByTestId("question-form-card")
|
||||||
|
.first()
|
||||||
|
.getByRole("button", { name: option })
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openQuestion(
|
||||||
|
page: Page,
|
||||||
|
input: { index: number; total: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await page
|
||||||
|
.getByTestId("question-form-card")
|
||||||
|
.first()
|
||||||
|
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fillQuestionAnswer(
|
||||||
|
page: Page,
|
||||||
|
input: { question: string; answer: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await page
|
||||||
|
.getByTestId("question-form-card")
|
||||||
|
.first()
|
||||||
|
.getByRole("textbox", { name: input.question })
|
||||||
|
.fill(input.answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitQuestionAnswers(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("question-form-primary-action").click();
|
||||||
|
await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { test } from "./fixtures";
|
||||||
|
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||||
|
import {
|
||||||
|
chooseQuestionOption,
|
||||||
|
expectCurrentQuestion,
|
||||||
|
expectQuestionHidden,
|
||||||
|
expectQuestionOptionSelected,
|
||||||
|
fillQuestionAnswer,
|
||||||
|
openQuestion,
|
||||||
|
submitQuestionAnswers,
|
||||||
|
waitForQuestionPrompt,
|
||||||
|
} from "./helpers/questions";
|
||||||
|
|
||||||
|
const TOTAL_QUESTIONS = 3;
|
||||||
|
const SURFACE_QUESTION = "Which surface should this apply to?";
|
||||||
|
const ROLLOUT_QUESTION = "Which rollout should we use?";
|
||||||
|
const SUCCESS_QUESTION = "What success criteria should we use?";
|
||||||
|
|
||||||
|
test.describe("Question prompt pagination", () => {
|
||||||
|
test("shows one question at a time with numbered navigation", async ({ page }) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "question-pagination-",
|
||||||
|
title: "Question pagination e2e",
|
||||||
|
initialPrompt: "Emit synthetic questions.",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await waitForQuestionPrompt(page, 120_000);
|
||||||
|
|
||||||
|
await expectCurrentQuestion(page, {
|
||||||
|
index: 1,
|
||||||
|
total: TOTAL_QUESTIONS,
|
||||||
|
question: SURFACE_QUESTION,
|
||||||
|
});
|
||||||
|
await expectQuestionHidden(page, ROLLOUT_QUESTION);
|
||||||
|
await expectQuestionHidden(page, SUCCESS_QUESTION);
|
||||||
|
|
||||||
|
await chooseQuestionOption(page, "App");
|
||||||
|
await expectCurrentQuestion(page, {
|
||||||
|
index: 2,
|
||||||
|
total: TOTAL_QUESTIONS,
|
||||||
|
question: ROLLOUT_QUESTION,
|
||||||
|
});
|
||||||
|
|
||||||
|
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
|
||||||
|
await expectCurrentQuestion(page, {
|
||||||
|
index: 1,
|
||||||
|
total: TOTAL_QUESTIONS,
|
||||||
|
question: SURFACE_QUESTION,
|
||||||
|
});
|
||||||
|
await expectQuestionOptionSelected(page, "App");
|
||||||
|
|
||||||
|
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||||
|
await chooseQuestionOption(page, "Behind feature flag");
|
||||||
|
await expectCurrentQuestion(page, {
|
||||||
|
index: 3,
|
||||||
|
total: TOTAL_QUESTIONS,
|
||||||
|
question: SUCCESS_QUESTION,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fillQuestionAnswer(page, {
|
||||||
|
question: SUCCESS_QUESTION,
|
||||||
|
answer: "Only one prompt is visible at a time.",
|
||||||
|
});
|
||||||
|
await submitQuestionAnswers(page);
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,8 +5,10 @@ import { injectDesktopBridge } from "./helpers/desktop-updates";
|
|||||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||||
|
|
||||||
interface EditorOpenRecord {
|
interface EditorOpenRecord {
|
||||||
command: string;
|
editorId: string;
|
||||||
args: string[];
|
path: string;
|
||||||
|
cwd?: string;
|
||||||
|
mode?: "open" | "reveal";
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireE2EEnv(name: string): string {
|
function requireE2EEnv(name: string): string {
|
||||||
@@ -43,8 +45,8 @@ async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise<void>
|
|||||||
|
|
||||||
async function expectEditorOpened(input: {
|
async function expectEditorOpened(input: {
|
||||||
recordPath: string;
|
recordPath: string;
|
||||||
command: string;
|
editorId: string;
|
||||||
workspacePath: string;
|
path: string;
|
||||||
afterCount: number;
|
afterCount: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await expect
|
await expect
|
||||||
@@ -53,10 +55,7 @@ async function expectEditorOpened(input: {
|
|||||||
const records = await readEditorOpenRecords(input.recordPath);
|
const records = await readEditorOpenRecords(input.recordPath);
|
||||||
return records
|
return records
|
||||||
.slice(input.afterCount)
|
.slice(input.afterCount)
|
||||||
.some(
|
.some((record) => record.editorId === input.editorId && record.path === input.path);
|
||||||
(record) =>
|
|
||||||
record.command === input.command && record.args.includes(input.workspacePath),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
@@ -73,7 +72,14 @@ test.describe("Workspace open in editor", () => {
|
|||||||
const serverId = requireE2EEnv("E2E_SERVER_ID");
|
const serverId = requireE2EEnv("E2E_SERVER_ID");
|
||||||
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
|
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
|
||||||
await rm(recordPath, { force: true });
|
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-" });
|
const workspace = await withWorkspace({ prefix: "workspace-editor-target-" });
|
||||||
await workspace.navigateTo();
|
await workspace.navigateTo();
|
||||||
@@ -81,8 +87,8 @@ test.describe("Workspace open in editor", () => {
|
|||||||
await chooseEditorTarget(page, "vscode");
|
await chooseEditorTarget(page, "vscode");
|
||||||
await expectEditorOpened({
|
await expectEditorOpened({
|
||||||
recordPath,
|
recordPath,
|
||||||
command: "code",
|
editorId: "vscode",
|
||||||
workspacePath: workspace.repoPath,
|
path: workspace.repoPath,
|
||||||
afterCount: 0,
|
afterCount: 0,
|
||||||
});
|
});
|
||||||
const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length;
|
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 page.getByTestId("workspace-open-in-editor-primary").click();
|
||||||
await expectEditorOpened({
|
await expectEditorOpened({
|
||||||
recordPath,
|
recordPath,
|
||||||
command: "code",
|
editorId: "vscode",
|
||||||
workspacePath: workspace.repoPath,
|
path: workspace.repoPath,
|
||||||
afterCount: recordsAfterSelection,
|
afterCount: recordsAfterSelection,
|
||||||
});
|
});
|
||||||
const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length;
|
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 page.getByTestId("workspace-open-in-editor-primary").click();
|
||||||
await expectEditorOpened({
|
await expectEditorOpened({
|
||||||
recordPath,
|
recordPath,
|
||||||
command: "code",
|
editorId: "vscode",
|
||||||
workspacePath: workspace.repoPath,
|
path: workspace.repoPath,
|
||||||
afterCount: recordsAfterReturnOpen,
|
afterCount: recordsAfterReturnOpen,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { SquareTerminal } from "lucide-react-native";
|
import { SquareTerminal } from "lucide-react-native";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Image, type ImageSourcePropType } from "react-native";
|
import { Image, type ImageSourcePropType } from "react-native";
|
||||||
import {
|
import { isKnownEditorTargetId, type EditorTargetId } from "@/workspace/editor-targets";
|
||||||
isKnownEditorTargetId,
|
|
||||||
type EditorTargetId,
|
|
||||||
type KnownEditorTargetId,
|
|
||||||
} from "@getpaseo/protocol/messages";
|
|
||||||
|
|
||||||
interface EditorAppIconProps {
|
interface EditorAppIconProps {
|
||||||
editorId: EditorTargetId;
|
editorId: EditorTargetId;
|
||||||
@@ -14,7 +10,7 @@ interface EditorAppIconProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* 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"),
|
cursor: require("../../../assets/images/editor-apps/cursor.png"),
|
||||||
vscode: require("../../../assets/images/editor-apps/vscode.png"),
|
vscode: require("../../../assets/images/editor-apps/vscode.png"),
|
||||||
webstorm: require("../../../assets/images/editor-apps/webstorm.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 */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
|
|
||||||
export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is KnownEditorTargetId {
|
export function hasBundledEditorAppIcon(editorId: EditorTargetId): boolean {
|
||||||
return isKnownEditorTargetId(editorId);
|
return isKnownEditorTargetId(editorId) && EDITOR_APP_IMAGES[editorId] !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
|
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
|
||||||
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
|
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 <SquareTerminal size={size} color={color} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Image source={EDITOR_APP_IMAGES[editorId]} style={imageStyle} resizeMode="contain" />;
|
return <Image source={source} style={imageStyle} resizeMode="contain" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
import { Check, X } from "lucide-react-native";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
@@ -82,9 +82,18 @@ function QuestionOptionRow({
|
|||||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||||
[theme.colors.foregroundMuted],
|
[theme.colors.foregroundMuted],
|
||||||
);
|
);
|
||||||
|
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable style={pressableStyle} onPress={handlePress} disabled={isResponding}>
|
<Pressable
|
||||||
|
style={pressableStyle}
|
||||||
|
onPress={handlePress}
|
||||||
|
disabled={isResponding}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={option.label}
|
||||||
|
accessibilityState={accessibilityState}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
>
|
||||||
<View style={styles.optionItemContent}>
|
<View style={styles.optionItemContent}>
|
||||||
<View style={styles.optionTextBlock}>
|
<View style={styles.optionTextBlock}>
|
||||||
<Text style={optionLabelStyle}>{option.label}</Text>
|
<Text style={optionLabelStyle}>{option.label}</Text>
|
||||||
@@ -102,8 +111,73 @@ function QuestionOptionRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QuestionNavButtonProps {
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
isActive: boolean;
|
||||||
|
isResponding: boolean;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionNavButton({
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
isActive,
|
||||||
|
isResponding,
|
||||||
|
onSelect,
|
||||||
|
}: QuestionNavButtonProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const accessibilityState = useMemo(() => ({ selected: isActive }), [isActive]);
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onSelect(index);
|
||||||
|
}, [index, onSelect]);
|
||||||
|
const pressableStyle = useCallback(
|
||||||
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||||
|
return [
|
||||||
|
styles.questionNavButton,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
isActive || Boolean(hovered) ? theme.colors.surface2 : theme.colors.surface1,
|
||||||
|
borderColor: isActive ? theme.colors.foregroundMuted : theme.colors.border,
|
||||||
|
},
|
||||||
|
pressed && styles.optionItemPressed,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isActive,
|
||||||
|
theme.colors.border,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
|
theme.colors.surface1,
|
||||||
|
theme.colors.surface2,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const textStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
styles.questionNavText,
|
||||||
|
{ color: isActive ? theme.colors.foreground : theme.colors.foregroundMuted },
|
||||||
|
],
|
||||||
|
[isActive, theme.colors.foreground, theme.colors.foregroundMuted],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Question ${index + 1} of ${total}`}
|
||||||
|
accessibilityState={accessibilityState}
|
||||||
|
aria-selected={isActive}
|
||||||
|
testID={`question-form-question-nav-${index + 1}`}
|
||||||
|
style={pressableStyle}
|
||||||
|
onPress={handlePress}
|
||||||
|
disabled={isResponding}
|
||||||
|
>
|
||||||
|
<Text style={textStyle}>{index + 1}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface QuestionOtherInputProps {
|
interface QuestionOtherInputProps {
|
||||||
qIndex: number;
|
qIndex: number;
|
||||||
|
accessibilityLabel: string;
|
||||||
value: string;
|
value: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
isResponding: boolean;
|
isResponding: boolean;
|
||||||
@@ -113,6 +187,7 @@ interface QuestionOtherInputProps {
|
|||||||
|
|
||||||
function QuestionOtherInput({
|
function QuestionOtherInput({
|
||||||
qIndex,
|
qIndex,
|
||||||
|
accessibilityLabel,
|
||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder,
|
||||||
isResponding,
|
isResponding,
|
||||||
@@ -149,6 +224,7 @@ function QuestionOtherInput({
|
|||||||
<TextInput
|
<TextInput
|
||||||
// @ts-expect-error - outlineStyle is web-only
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
style={otherInputStyle}
|
style={otherInputStyle}
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -163,15 +239,19 @@ function QuestionOtherInput({
|
|||||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const questions = parseQuestionFormQuestions(permission.request.input);
|
const questions = useMemo(
|
||||||
|
() => parseQuestionFormQuestions(permission.request.input),
|
||||||
|
[permission.request.input],
|
||||||
|
);
|
||||||
|
|
||||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||||
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
||||||
|
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
|
||||||
|
|
||||||
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
|
const toggleOption = useCallback(
|
||||||
setSelections((prev) => {
|
(qIndex: number, optIndex: number, multiSelect: boolean) => {
|
||||||
const current = prev[qIndex] ?? new Set<number>();
|
const current = selections[qIndex] ?? new Set<number>();
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
if (multiSelect) {
|
if (multiSelect) {
|
||||||
if (next.has(optIndex)) {
|
if (next.has(optIndex)) {
|
||||||
@@ -179,23 +259,27 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
} else {
|
} else {
|
||||||
next.add(optIndex);
|
next.add(optIndex);
|
||||||
}
|
}
|
||||||
|
} else if (next.has(optIndex)) {
|
||||||
|
next.clear();
|
||||||
} else {
|
} else {
|
||||||
if (next.has(optIndex)) {
|
next.clear();
|
||||||
next.clear();
|
next.add(optIndex);
|
||||||
} else {
|
|
||||||
next.clear();
|
|
||||||
next.add(optIndex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { ...prev, [qIndex]: next };
|
|
||||||
});
|
setSelections((prev) => ({ ...prev, [qIndex]: next }));
|
||||||
setOtherTexts((prev) => {
|
setOtherTexts((prev) => {
|
||||||
if (!prev[qIndex]) return prev;
|
if (!prev[qIndex]) return prev;
|
||||||
const next = { ...prev };
|
const nextTexts = { ...prev };
|
||||||
delete next[qIndex];
|
delete nextTexts[qIndex];
|
||||||
return next;
|
return nextTexts;
|
||||||
});
|
});
|
||||||
}, []);
|
|
||||||
|
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
|
||||||
|
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeQuestionIndex, questions, selections],
|
||||||
|
);
|
||||||
|
|
||||||
const setOtherText = useCallback((qIndex: number, text: string) => {
|
const setOtherText = useCallback((qIndex: number, text: string) => {
|
||||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||||
@@ -208,6 +292,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
||||||
|
const resolvedActiveQuestionIndex = questions
|
||||||
|
? Math.min(activeQuestionIndex, questions.length - 1)
|
||||||
|
: 0;
|
||||||
|
const activeQuestion = questions?.[resolvedActiveQuestionIndex];
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (!questions || !allAnswered || isResponding) return;
|
if (!questions || !allAnswered || isResponding) return;
|
||||||
@@ -248,6 +336,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
});
|
});
|
||||||
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
|
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
|
||||||
|
|
||||||
|
const handleSelectQuestion = useCallback((index: number) => {
|
||||||
|
setActiveQuestionIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const dismissButtonStyle = useCallback(
|
const dismissButtonStyle = useCallback(
|
||||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.actionButton,
|
styles.actionButton,
|
||||||
@@ -262,22 +354,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
|
|
||||||
const submitDisabled = !allAnswered || isResponding;
|
const submitDisabled = !allAnswered || isResponding;
|
||||||
const submitButtonStyle = useCallback(
|
const submitButtonStyle = useCallback(
|
||||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.actionButton,
|
styles.actionButton,
|
||||||
{
|
{
|
||||||
backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1,
|
backgroundColor: theme.colors.accent,
|
||||||
borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent,
|
borderColor: theme.colors.accent,
|
||||||
opacity: submitDisabled ? 0.5 : 1,
|
opacity: submitDisabled ? 0.5 : 1,
|
||||||
},
|
},
|
||||||
pressed && !submitDisabled ? styles.optionItemPressed : null,
|
pressed && !submitDisabled ? styles.optionItemPressed : null,
|
||||||
],
|
],
|
||||||
[
|
[submitDisabled, theme.colors.accent],
|
||||||
submitDisabled,
|
|
||||||
theme.colors.surface2,
|
|
||||||
theme.colors.surface1,
|
|
||||||
theme.colors.border,
|
|
||||||
theme.colors.borderAccent,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
@@ -294,6 +380,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
() => [styles.questionText, { color: theme.colors.foreground }],
|
() => [styles.questionText, { color: theme.colors.foreground }],
|
||||||
[theme.colors.foreground],
|
[theme.colors.foreground],
|
||||||
);
|
);
|
||||||
|
const questionNavStyle = useMemo(
|
||||||
|
() => [styles.questionNav, isMobile && styles.questionNavMobile],
|
||||||
|
[isMobile],
|
||||||
|
);
|
||||||
const actionsContainerStyle = useMemo(
|
const actionsContainerStyle = useMemo(
|
||||||
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
||||||
[isMobile],
|
[isMobile],
|
||||||
@@ -302,9 +392,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
() => [styles.actionText, { color: theme.colors.foregroundMuted }],
|
() => [styles.actionText, { color: theme.colors.foregroundMuted }],
|
||||||
[theme.colors.foregroundMuted],
|
[theme.colors.foregroundMuted],
|
||||||
);
|
);
|
||||||
const submitActionTextColor = allAnswered
|
const submitActionTextColor = theme.colors.accentForeground;
|
||||||
? theme.colors.foreground
|
|
||||||
: theme.colors.foregroundMuted;
|
|
||||||
const submitActionTextStyle = useMemo(
|
const submitActionTextStyle = useMemo(
|
||||||
() => [styles.actionText, { color: submitActionTextColor }],
|
() => [styles.actionText, { color: submitActionTextColor }],
|
||||||
[submitActionTextColor],
|
[submitActionTextColor],
|
||||||
@@ -315,52 +403,76 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dismissLabel = resolveDismissLabel(questions);
|
const dismissLabel = resolveDismissLabel(questions);
|
||||||
|
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
|
||||||
|
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
|
||||||
|
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle} testID="question-form-card">
|
||||||
{questions.map((q, qIndex) => {
|
<View style={styles.questionTopRow}>
|
||||||
const selected = selections[qIndex] ?? new Set<number>();
|
<View style={styles.questionHeader}>
|
||||||
const otherText = otherTexts[qIndex] ?? "";
|
<Text testID="question-form-current-question" style={questionTextStyle}>
|
||||||
const showTextInput = questionShowsTextInput(q);
|
{activeQuestion?.question}
|
||||||
|
</Text>
|
||||||
return (
|
</View>
|
||||||
<View key={q.question} style={styles.questionBlock}>
|
<View style={questionNavStyle} testID="question-form-question-nav">
|
||||||
<View style={styles.questionHeader}>
|
{questions.map((question, qIndex) => {
|
||||||
<Text style={questionTextStyle}>{q.question}</Text>
|
const isActive = qIndex === resolvedActiveQuestionIndex;
|
||||||
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
return (
|
||||||
</View>
|
<QuestionNavButton
|
||||||
{q.options.length > 0 ? (
|
key={question.header}
|
||||||
<View style={styles.optionsWrap}>
|
index={qIndex}
|
||||||
{q.options.map((opt, optIndex) => (
|
total={questions.length}
|
||||||
<QuestionOptionRow
|
isActive={isActive}
|
||||||
key={opt.label}
|
|
||||||
qIndex={qIndex}
|
|
||||||
optIndex={optIndex}
|
|
||||||
option={opt}
|
|
||||||
isSelected={selected.has(optIndex)}
|
|
||||||
multiSelect={q.multiSelect}
|
|
||||||
isResponding={isResponding}
|
|
||||||
onToggle={toggleOption}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{showTextInput ? (
|
|
||||||
<QuestionOtherInput
|
|
||||||
qIndex={qIndex}
|
|
||||||
value={otherText}
|
|
||||||
placeholder={getQuestionInputPlaceholder(q)}
|
|
||||||
isResponding={isResponding}
|
isResponding={isResponding}
|
||||||
onChange={setOtherText}
|
onSelect={handleSelectQuestion}
|
||||||
onSubmit={handleSubmit}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
);
|
||||||
</View>
|
})}
|
||||||
);
|
</View>
|
||||||
})}
|
</View>
|
||||||
|
|
||||||
|
{activeQuestion ? (
|
||||||
|
<View key={activeQuestion.question} style={styles.questionBlock}>
|
||||||
|
{activeQuestion.options.length > 0 ? (
|
||||||
|
<View style={styles.optionsWrap}>
|
||||||
|
{activeQuestion.options.map((opt, optIndex) => (
|
||||||
|
<QuestionOptionRow
|
||||||
|
key={opt.label}
|
||||||
|
qIndex={resolvedActiveQuestionIndex}
|
||||||
|
optIndex={optIndex}
|
||||||
|
option={opt}
|
||||||
|
isSelected={selected.has(optIndex)}
|
||||||
|
multiSelect={activeQuestion.multiSelect}
|
||||||
|
isResponding={isResponding}
|
||||||
|
onToggle={toggleOption}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{showTextInput ? (
|
||||||
|
<QuestionOtherInput
|
||||||
|
qIndex={resolvedActiveQuestionIndex}
|
||||||
|
accessibilityLabel={activeQuestion.question}
|
||||||
|
value={otherText}
|
||||||
|
placeholder={getQuestionInputPlaceholder(activeQuestion)}
|
||||||
|
isResponding={isResponding}
|
||||||
|
onChange={setOtherText}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View style={actionsContainerStyle}>
|
<View style={actionsContainerStyle}>
|
||||||
<Pressable style={dismissButtonStyle} onPress={handleDeny} disabled={isResponding}>
|
<Pressable
|
||||||
|
style={dismissButtonStyle}
|
||||||
|
onPress={handleDeny}
|
||||||
|
disabled={isResponding}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={dismissLabel}
|
||||||
|
testID="question-form-dismiss"
|
||||||
|
>
|
||||||
{respondingAction === "dismiss" ? (
|
{respondingAction === "dismiss" ? (
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
) : (
|
) : (
|
||||||
@@ -371,9 +483,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable style={submitButtonStyle} onPress={handleSubmit} disabled={submitDisabled}>
|
<Pressable
|
||||||
|
style={submitButtonStyle}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={submitDisabled}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Submit"
|
||||||
|
testID="question-form-primary-action"
|
||||||
|
>
|
||||||
{respondingAction === "submit" ? (
|
{respondingAction === "submit" ? (
|
||||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.actionContent}>
|
<View style={styles.actionContent}>
|
||||||
<Check size={14} color={submitActionTextColor} />
|
<Check size={14} color={submitActionTextColor} />
|
||||||
@@ -396,21 +515,50 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
questionBlock: {
|
questionBlock: {
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
questionTopRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
questionHeader: {
|
questionHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingBottom: theme.spacing[1],
|
paddingBottom: theme.spacing[1],
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
questionText: {
|
questionText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
},
|
},
|
||||||
optionsWrap: {
|
optionsWrap: {
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
questionNav: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
questionNavMobile: {
|
||||||
|
paddingRight: theme.spacing[1],
|
||||||
|
},
|
||||||
|
questionNavButton: {
|
||||||
|
minWidth: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
},
|
||||||
|
questionNavText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
optionItem: {
|
optionItem: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import { SettingsSection } from "@/screens/settings/settings-section";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import {
|
import { shouldUseDesktopDaemon, type SkillsStatus } from "@/desktop/daemon/desktop-daemon";
|
||||||
shouldUseDesktopDaemon,
|
|
||||||
type SkillOp,
|
|
||||||
type SkillsStatus,
|
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
|
||||||
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
||||||
|
|
||||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||||
@@ -21,21 +17,6 @@ const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
|||||||
const UNINSTALL_MESSAGE =
|
const UNINSTALL_MESSAGE =
|
||||||
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
|
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
|
||||||
|
|
||||||
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
|
|
||||||
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
|
|
||||||
add: "Add skill",
|
|
||||||
update: "Update skill",
|
|
||||||
delete: "Delete skill",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatUpdateMessage(ops: readonly SkillOp[]): string {
|
|
||||||
const sorted = [...ops].sort((a, b) => {
|
|
||||||
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
|
|
||||||
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IntegrationsSection() {
|
export function IntegrationsSection() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const showSection = shouldUseDesktopDaemon();
|
const showSection = shouldUseDesktopDaemon();
|
||||||
@@ -75,15 +56,8 @@ export function IntegrationsSection() {
|
|||||||
|
|
||||||
const handleUpdateSkills = useCallback(async () => {
|
const handleUpdateSkills = useCallback(async () => {
|
||||||
if (isSkillsWorking) return;
|
if (isSkillsWorking) return;
|
||||||
const ops = skillsStatus?.ops ?? [];
|
|
||||||
const confirmed = await confirmDialog({
|
|
||||||
title: "Update Paseo skills?",
|
|
||||||
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
|
|
||||||
confirmLabel: "Update",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
await updateSkills();
|
await updateSkills();
|
||||||
}, [isSkillsWorking, skillsStatus, updateSkills]);
|
}, [isSkillsWorking, updateSkills]);
|
||||||
|
|
||||||
const handleUninstallSkills = useCallback(async () => {
|
const handleUninstallSkills = useCallback(async () => {
|
||||||
if (isSkillsWorking) return;
|
if (isSkillsWorking) return;
|
||||||
|
|||||||
@@ -51,6 +51,24 @@ export interface DesktopOpenerBridge {
|
|||||||
openUrl?: (url: string) => Promise<void>;
|
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 {
|
export interface DesktopWebUtilsBridge {
|
||||||
getPathForFile?: (file: File) => string;
|
getPathForFile?: (file: File) => string;
|
||||||
}
|
}
|
||||||
@@ -111,6 +129,7 @@ export interface DesktopHostBridge {
|
|||||||
dialog?: DesktopDialogBridge;
|
dialog?: DesktopDialogBridge;
|
||||||
notification?: DesktopNotificationBridge;
|
notification?: DesktopNotificationBridge;
|
||||||
opener?: DesktopOpenerBridge;
|
opener?: DesktopOpenerBridge;
|
||||||
|
editor?: DesktopEditorBridge;
|
||||||
webUtils?: DesktopWebUtilsBridge;
|
webUtils?: DesktopWebUtilsBridge;
|
||||||
menu?: DesktopMenuBridge;
|
menu?: DesktopMenuBridge;
|
||||||
browser?: DesktopBrowserBridge;
|
browser?: DesktopBrowserBridge;
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-url";
|
import {
|
||||||
|
buildGitHubBlobUrl,
|
||||||
|
buildGitHubBranchTreeUrl,
|
||||||
|
parseGitHubRepoFromRemote,
|
||||||
|
} from "./github-url";
|
||||||
|
|
||||||
describe("parseGitHubRepoFromRemote", () => {
|
describe("parseGitHubRepoFromRemote", () => {
|
||||||
it.each([
|
it.each([
|
||||||
@@ -51,3 +55,78 @@ describe("buildGitHubBranchTreeUrl", () => {
|
|||||||
).toBeNull();
|
).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,3 +20,50 @@ export function buildGitHubBranchTreeUrl(input: {
|
|||||||
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
|
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
|
||||||
return `https://github.com/${repo}/tree/${encodedBranch}`;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
it("returns null when no editors are available", () => {
|
||||||
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
|
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
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_STORAGE_KEY = "@paseo:preferred-editor";
|
||||||
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
|
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
|
||||||
@@ -11,8 +11,7 @@ async function loadPreferredEditor(): Promise<EditorTargetId | null> {
|
|||||||
if (!stored) {
|
if (!stored) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = EditorTargetIdSchema.safeParse(stored);
|
return stored.trim() || null;
|
||||||
return parsed.success ? parsed.data : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePreferredEditorId(
|
export function resolvePreferredEditorId(
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import {
|
|||||||
View,
|
View,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Check, ChevronDown } from "lucide-react-native";
|
import { Check, ChevronDown } from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import type { EditorTargetDescriptorPayload } from "@getpaseo/protocol/messages";
|
|
||||||
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
|
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
|
||||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
import {
|
import {
|
||||||
@@ -21,18 +20,20 @@ import {
|
|||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
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 { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||||
import { buildGitHubBranchTreeUrl } from "@/git/github-url";
|
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { isAbsolutePath } from "@/utils/path";
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
import { isWeb } from "@/constants/platform";
|
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 type { Theme } from "@/styles/theme";
|
||||||
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
|
|
||||||
|
|
||||||
interface WorkspaceOpenInEditorButtonProps {
|
interface WorkspaceOpenInEditorButtonProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
activeFile?: WorkspaceFileLocation | null;
|
||||||
hideLabels?: boolean;
|
hideLabels?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +41,6 @@ interface OpenTarget {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: ReactElement;
|
icon: ReactElement;
|
||||||
requiresLocalDaemon: boolean;
|
|
||||||
onOpen: () => Promise<void> | void;
|
onOpen: () => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,95 +80,71 @@ function OpenTargetMenuItem({ target, isPreferred, onOpen }: OpenTargetMenuItemP
|
|||||||
export function WorkspaceOpenInEditorButton({
|
export function WorkspaceOpenInEditorButton({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
|
activeFile,
|
||||||
hideLabels,
|
hideLabels,
|
||||||
}: WorkspaceOpenInEditorButtonProps) {
|
}: WorkspaceOpenInEditorButtonProps) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const client = useHostRuntimeClient(serverId);
|
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||||
|
const { targets: desktopOpenTargets, isAvailable: isDesktopOpenAvailable } =
|
||||||
|
useDesktopOpenTargets({
|
||||||
|
isLocalExecution: isLocalDaemon,
|
||||||
|
});
|
||||||
|
|
||||||
const shouldQueryWorkspace =
|
const resolvedFile = useMemo(
|
||||||
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
() =>
|
||||||
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
|
activeFile ? resolveWorkspaceFilePaths({ path: activeFile.path, workspaceRoot: cwd }) : null,
|
||||||
|
[activeFile, cwd],
|
||||||
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 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({
|
const { status: checkoutStatus } = useCheckoutStatusQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd: shouldQueryWorkspace ? cwd : "",
|
cwd: shouldQueryCheckout ? cwd : "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorTargets = useMemo<OpenTarget[]>(
|
const targets = useMemo<OpenTarget[]>(
|
||||||
() =>
|
() =>
|
||||||
availableEditors.map((editor) => ({
|
planWorkspaceOpenTargets({
|
||||||
id: editor.id,
|
workspaceDirectory: cwd,
|
||||||
label: editor.label,
|
activeFile,
|
||||||
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
resolvedActiveFile: resolvedFile,
|
||||||
requiresLocalDaemon: true,
|
desktopTargets: desktopOpenTargets,
|
||||||
onOpen: async () => {
|
canUseDesktopBridge: isDesktopOpenAvailable,
|
||||||
if (!client) {
|
isLocalExecution: isLocalDaemon,
|
||||||
throw new Error("Host is not connected");
|
checkoutStatus,
|
||||||
}
|
}).map((target) => {
|
||||||
const payload = await client.openInEditor(cwd, editor.id);
|
if (target.source === "github") {
|
||||||
if (payload.error) {
|
return {
|
||||||
throw new Error(payload.error);
|
id: target.id,
|
||||||
}
|
label: target.label,
|
||||||
},
|
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
||||||
})),
|
onOpen: () => openExternalUrl(target.url),
|
||||||
[availableEditors, client, cwd],
|
};
|
||||||
);
|
}
|
||||||
|
return {
|
||||||
const githubTarget = useMemo<OpenTarget | null>(() => {
|
id: target.id,
|
||||||
if (!checkoutStatus?.isGit) {
|
label: target.label,
|
||||||
return null;
|
icon: <ThemedEditorAppIcon editorId={target.id} size={16} uniProps={mutedColorMapping} />,
|
||||||
}
|
onOpen: () => openDesktopTarget(target.openInput),
|
||||||
const url = buildGitHubBranchTreeUrl({
|
};
|
||||||
remoteUrl: checkoutStatus.remoteUrl,
|
}),
|
||||||
branch: checkoutStatus.currentBranch,
|
[
|
||||||
});
|
activeFile,
|
||||||
if (!url) {
|
checkoutStatus,
|
||||||
return null;
|
cwd,
|
||||||
}
|
desktopOpenTargets,
|
||||||
return {
|
isDesktopOpenAvailable,
|
||||||
id: "github",
|
isLocalDaemon,
|
||||||
label: "GitHub",
|
resolvedFile,
|
||||||
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
],
|
||||||
requiresLocalDaemon: false,
|
|
||||||
onOpen: () => openExternalUrl(url),
|
|
||||||
};
|
|
||||||
}, [checkoutStatus]);
|
|
||||||
|
|
||||||
const targets = useMemo(
|
|
||||||
() =>
|
|
||||||
filterTargetsForDaemonLocation(
|
|
||||||
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
|
|
||||||
{
|
|
||||||
isLocalDaemon,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
[editorTargets, githubTarget, isLocalDaemon],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
|
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
|
||||||
@@ -216,7 +192,7 @@ export function WorkspaceOpenInEditorButton({
|
|||||||
}
|
}
|
||||||
}, [primaryOption, handleOpenTarget]);
|
}, [primaryOption, handleOpenTarget]);
|
||||||
|
|
||||||
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) {
|
if (!canResolveWorkspace || !primaryOption || targets.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +205,11 @@ export function WorkspaceOpenInEditorButton({
|
|||||||
onPress={handlePrimaryPress}
|
onPress={handlePrimaryPress}
|
||||||
disabled={openMutation.isPending}
|
disabled={openMutation.isPending}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
|
accessibilityLabel={
|
||||||
|
activeFileName
|
||||||
|
? `Open ${activeFileName} in ${primaryOption.label}`
|
||||||
|
: `Open workspace in ${primaryOption.label}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{openMutation.isPending ? (
|
{openMutation.isPending ? (
|
||||||
<ThemedActivityIndicator
|
<ThemedActivityIndicator
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -193,6 +193,31 @@ function getWorkspaceScripts(
|
|||||||
return workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS;
|
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 ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||||
const ThemedEllipsis = withUnistyles(Ellipsis);
|
const ThemedEllipsis = withUnistyles(Ellipsis);
|
||||||
const ThemedEllipsisVertical = withUnistyles(EllipsisVertical);
|
const ThemedEllipsisVertical = withUnistyles(EllipsisVertical);
|
||||||
@@ -2851,6 +2876,19 @@ function WorkspaceScreenContent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activeTabDescriptor = useMemo(() => activeTab?.descriptor ?? null, [activeTab]);
|
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 canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
|
||||||
const shouldRenderDesktopPaneFallback = useMemo(
|
const shouldRenderDesktopPaneFallback = useMemo(
|
||||||
() => !isMobile && !canRenderDesktopPaneSplits,
|
() => !isMobile && !canRenderDesktopPaneSplits,
|
||||||
@@ -3083,10 +3121,11 @@ function WorkspaceScreenContent({
|
|||||||
hideLabels={showCompactButtonLabels}
|
hideLabels={showCompactButtonLabels}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!isMobile ? (
|
{!isMobile && workspaceDirectory ? (
|
||||||
<WorkspaceOpenInEditorButton
|
<WorkspaceOpenInEditorButton
|
||||||
serverId={normalizedServerId}
|
serverId={normalizedServerId}
|
||||||
cwd={normalizedWorkspaceId}
|
cwd={workspaceDirectory}
|
||||||
|
activeFile={activeFileLocation}
|
||||||
hideLabels={showCompactButtonLabels}
|
hideLabels={showCompactButtonLabels}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -3193,6 +3232,8 @@ function WorkspaceScreenContent({
|
|||||||
workspaceDescriptor,
|
workspaceDescriptor,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
|
workspaceDirectory,
|
||||||
|
activeFileLocation,
|
||||||
liveTerminalIds,
|
liveTerminalIds,
|
||||||
handleScriptTerminalStarted,
|
handleScriptTerminalStarted,
|
||||||
handleViewScriptTerminal,
|
handleViewScriptTerminal,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
createWorkspaceFileTabTarget,
|
createWorkspaceFileTabTarget,
|
||||||
normalizeWorkspaceFileLocation,
|
normalizeWorkspaceFileLocation,
|
||||||
|
resolveWorkspaceFilePaths,
|
||||||
workspaceFileLocationsEqual,
|
workspaceFileLocationsEqual,
|
||||||
} from ".";
|
} from ".";
|
||||||
|
|
||||||
@@ -61,3 +62,124 @@ describe("workspace file tab targets", () => {
|
|||||||
).toBe(false);
|
).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();
|
||||||
|
expect(resolveWorkspaceFilePaths({ path: "~", workspaceRoot: "/Users/me/repo" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats filenames that merely start with ~ as workspace-relative", () => {
|
||||||
|
expect(resolveWorkspaceFilePaths({ path: "~env.ts", workspaceRoot: "/Users/me/repo" })).toEqual(
|
||||||
|
{
|
||||||
|
absolutePath: "/Users/me/repo/~env.ts",
|
||||||
|
relativePath: "~env.ts",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches Windows workspace paths case-insensitively", () => {
|
||||||
|
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("treats a case-only difference from the Windows root as the root itself", () => {
|
||||||
|
expect(
|
||||||
|
resolveWorkspaceFilePaths({
|
||||||
|
path: "C:\\Users\\Me\\Repo",
|
||||||
|
workspaceRoot: "c:\\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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
|
|
||||||
export type OpenFileDisposition = "main" | "side";
|
export type OpenFileDisposition = "main" | "side";
|
||||||
|
|
||||||
export interface WorkspaceFileLocation {
|
export interface WorkspaceFileLocation {
|
||||||
@@ -57,3 +59,120 @@ function normalizeLineNumber(value: number | null | undefined): number | undefin
|
|||||||
? Math.floor(value)
|
? Math.floor(value)
|
||||||
: undefined;
|
: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWindowsPath(value: string): boolean {
|
||||||
|
return /^[A-Za-z]:\//.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathsEqual(left: string, right: string): boolean {
|
||||||
|
return isWindowsPath(left) || isWindowsPath(right)
|
||||||
|
? left.toLowerCase() === right.toLowerCase()
|
||||||
|
: left === right;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWithPath(value: string, prefix: string): boolean {
|
||||||
|
return isWindowsPath(value) || isWindowsPath(prefix)
|
||||||
|
? value.toLowerCase().startsWith(prefix.toLowerCase())
|
||||||
|
: value.startsWith(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (pathsEqual(normalizedFile, workspaceRoot)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const prefix = `${workspaceRoot}/`;
|
||||||
|
const relativePath = startsWithPath(normalizedFile, prefix)
|
||||||
|
? normalizedFile.slice(prefix.length)
|
||||||
|
: null;
|
||||||
|
return { absolutePath: normalizedFile, relativePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePath === "~" || filePath.startsWith("~/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = normalizeRelativePath(filePath);
|
||||||
|
if (!relativePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -50,8 +50,6 @@ import type {
|
|||||||
PaseoWorktreeListResponse,
|
PaseoWorktreeListResponse,
|
||||||
PaseoWorktreeArchiveResponse,
|
PaseoWorktreeArchiveResponse,
|
||||||
ProjectIconResponse,
|
ProjectIconResponse,
|
||||||
ListAvailableEditorsResponseMessage,
|
|
||||||
OpenInEditorResponseMessage,
|
|
||||||
OpenProjectResponseMessage,
|
OpenProjectResponseMessage,
|
||||||
ArchiveWorkspaceResponseMessage,
|
ArchiveWorkspaceResponseMessage,
|
||||||
WorkspaceSetupStatusResponseMessage,
|
WorkspaceSetupStatusResponseMessage,
|
||||||
@@ -77,7 +75,6 @@ import type {
|
|||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
SendAgentMessageRequest,
|
SendAgentMessageRequest,
|
||||||
EditorTargetId,
|
|
||||||
PaseoConfigRaw,
|
PaseoConfigRaw,
|
||||||
PaseoConfigRevision,
|
PaseoConfigRevision,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
@@ -641,12 +638,9 @@ export interface RenameTerminalInput {
|
|||||||
title: string;
|
title: 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"];
|
||||||
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
|
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
|
||||||
export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number];
|
|
||||||
|
|
||||||
export interface FetchAgentResult {
|
export interface FetchAgentResult {
|
||||||
agent: AgentSnapshotPayload;
|
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(
|
async archiveWorkspace(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
|
|||||||
@@ -36,6 +36,24 @@ describe("desktop startup", () => {
|
|||||||
expect(calls).toEqual(["cli", "env", "gui"]);
|
expect(calls).toEqual(["cli", "env", "gui"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts skills auto-update after GUI startup", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
await runDesktopStartup({
|
||||||
|
hasPendingOpenProjectPath: false,
|
||||||
|
runCliPassthroughIfRequested: vi.fn(async () => {
|
||||||
|
calls.push("cli");
|
||||||
|
return false;
|
||||||
|
}),
|
||||||
|
inheritLoginShellEnv: vi.fn(() => calls.push("env")),
|
||||||
|
bootstrapGui: vi.fn(async () => {
|
||||||
|
calls.push("gui");
|
||||||
|
}),
|
||||||
|
autoUpdateInstalledSkills: vi.fn(() => calls.push("skills")),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls).toEqual(["cli", "env", "gui", "skills"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not route open-project launches through CLI passthrough", async () => {
|
it("does not route open-project launches through CLI passthrough", async () => {
|
||||||
const runCliPassthroughIfRequested = vi.fn(async () => true);
|
const runCliPassthroughIfRequested = vi.fn(async () => true);
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface DesktopStartupDependencies {
|
|||||||
runCliPassthroughIfRequested: () => Promise<boolean>;
|
runCliPassthroughIfRequested: () => Promise<boolean>;
|
||||||
inheritLoginShellEnv: () => void;
|
inheritLoginShellEnv: () => void;
|
||||||
bootstrapGui: () => Promise<void>;
|
bootstrapGui: () => Promise<void>;
|
||||||
|
autoUpdateInstalledSkills?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise<void> {
|
export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise<void> {
|
||||||
@@ -12,4 +13,5 @@ export async function runDesktopStartup(deps: DesktopStartupDependencies): Promi
|
|||||||
|
|
||||||
deps.inheritLoginShellEnv();
|
deps.inheritLoginShellEnv();
|
||||||
await deps.bootstrapGui();
|
await deps.bootstrapGui();
|
||||||
|
deps.autoUpdateInstalledSkills?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export {
|
export {
|
||||||
|
autoUpdateInstalledSkills,
|
||||||
getSkillsStatus,
|
getSkillsStatus,
|
||||||
installSkills,
|
installSkills,
|
||||||
uninstallSkills,
|
uninstallSkills,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ vi.mock("electron", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
autoUpdateInstalledSkills,
|
||||||
getSkillsStatus,
|
getSkillsStatus,
|
||||||
installSkills,
|
installSkills,
|
||||||
PASEO_SKILL_NAMES,
|
PASEO_SKILL_NAMES,
|
||||||
@@ -60,6 +61,18 @@ async function writeOnDiskSkill(
|
|||||||
await writeFiles(path.join(agentsDir, name), files);
|
await writeFiles(path.join(agentsDir, name), files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeOnDiskSkillToAllTargets(
|
||||||
|
targets: SkillTargets,
|
||||||
|
name: string,
|
||||||
|
files: Record<string, string>,
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
writeOnDiskSkill(targets.agentsDir, name, files),
|
||||||
|
writeOnDiskSkill(targets.claudeDir, name, files),
|
||||||
|
writeOnDiskSkill(targets.codexDir, name, files),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
async function writeCurrentBundle(sourceDir: string): Promise<void> {
|
async function writeCurrentBundle(sourceDir: string): Promise<void> {
|
||||||
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
|
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||||
@@ -112,8 +125,22 @@ describe("getSkillsStatus", () => {
|
|||||||
|
|
||||||
it("returns up-to-date when every bundled skill matches on disk", async () => {
|
it("returns up-to-date when every bundled skill matches on disk", async () => {
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||||
|
|
||||||
|
const status = await getSkillsStatus(sandbox.targets);
|
||||||
|
|
||||||
|
expect(status).toEqual({ state: "up-to-date", ops: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores user-added files inside current managed skill dirs", async () => {
|
||||||
|
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||||
|
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
|
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", {
|
||||||
|
"SKILL.md": "paseo-v1",
|
||||||
|
"my-context.md": "user context",
|
||||||
|
});
|
||||||
|
|
||||||
const status = await getSkillsStatus(sandbox.targets);
|
const status = await getSkillsStatus(sandbox.targets);
|
||||||
|
|
||||||
@@ -123,7 +150,24 @@ describe("getSkillsStatus", () => {
|
|||||||
it("returns drift with a single update op when one bundled file diverges", async () => {
|
it("returns drift with a single update op when one bundled file diverges", async () => {
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
|
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||||
|
|
||||||
|
const status = await getSkillsStatus(sandbox.targets);
|
||||||
|
|
||||||
|
expect(status.state).toBe("drift");
|
||||||
|
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns drift when a secondary agent target is stale", async () => {
|
||||||
|
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||||
|
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "stale" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||||
|
|
||||||
const status = await getSkillsStatus(sandbox.targets);
|
const status = await getSkillsStatus(sandbox.targets);
|
||||||
|
|
||||||
@@ -133,7 +177,7 @@ describe("getSkillsStatus", () => {
|
|||||||
|
|
||||||
it("returns drift with add ops for the bundled skills missing from disk", async () => {
|
it("returns drift with add ops for the bundled skills missing from disk", async () => {
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
|
|
||||||
const status = await getSkillsStatus(sandbox.targets);
|
const status = await getSkillsStatus(sandbox.targets);
|
||||||
|
|
||||||
@@ -143,8 +187,8 @@ describe("getSkillsStatus", () => {
|
|||||||
|
|
||||||
it("returns drift with a delete op for a legacy skill name still on disk", async () => {
|
it("returns drift with a delete op for a legacy skill name still on disk", async () => {
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||||
|
|
||||||
const status = await getSkillsStatus(sandbox.targets);
|
const status = await getSkillsStatus(sandbox.targets);
|
||||||
@@ -156,6 +200,8 @@ describe("getSkillsStatus", () => {
|
|||||||
it("emits add + update + delete ops sorted by name when state is mixed", async () => {
|
it("emits add + update + delete ops sorted by name when state is mixed", async () => {
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||||
|
|
||||||
const status = await getSkillsStatus(sandbox.targets);
|
const status = await getSkillsStatus(sandbox.targets);
|
||||||
@@ -226,6 +272,51 @@ describe("installSkills / updateSkills", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("repairs secondary agent targets even when agents skills are current", async () => {
|
||||||
|
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||||
|
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||||
|
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-loop", { "SKILL.md": "loop-v1" });
|
||||||
|
|
||||||
|
const status = await updateSkills(sandbox.targets);
|
||||||
|
|
||||||
|
expect(status).toEqual({ state: "up-to-date", ops: [] });
|
||||||
|
expect(
|
||||||
|
await fs.readFile(path.join(sandbox.targets.claudeDir, "paseo-loop", "SKILL.md"), "utf-8"),
|
||||||
|
).toBe("loop-v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-updates drifted installed skills", async () => {
|
||||||
|
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||||
|
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
||||||
|
|
||||||
|
const status = await autoUpdateInstalledSkills(sandbox.targets);
|
||||||
|
|
||||||
|
expect(status).toEqual({ state: "up-to-date", ops: [] });
|
||||||
|
expect(
|
||||||
|
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
|
||||||
|
).toBe("paseo-v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-install skills on a clean machine", async () => {
|
||||||
|
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||||
|
|
||||||
|
const status = await autoUpdateInstalledSkills(sandbox.targets);
|
||||||
|
|
||||||
|
expect(status).toEqual({
|
||||||
|
state: "not-installed",
|
||||||
|
ops: [
|
||||||
|
{ kind: "add", name: "paseo" },
|
||||||
|
{ kind: "add", name: "paseo-loop" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(await pathExists(path.join(sandbox.targets.agentsDir, "paseo"))).toBe(false);
|
||||||
|
expect(await pathExists(path.join(sandbox.targets.claudeDir, "paseo"))).toBe(false);
|
||||||
|
expect(await pathExists(path.join(sandbox.targets.codexDir, "paseo"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("is idempotent — running install twice keeps state at up-to-date", async () => {
|
it("is idempotent — running install twice keeps state at up-to-date", async () => {
|
||||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const PASEO_SKILL_NAMES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type SkillFiles = Map<string, string>;
|
type SkillFiles = Map<string, string>;
|
||||||
|
type TargetSkills = Map<string, SkillFiles>;
|
||||||
|
|
||||||
function resolveSkillTargets(): SkillTargets {
|
function resolveSkillTargets(): SkillTargets {
|
||||||
return {
|
return {
|
||||||
@@ -74,23 +75,34 @@ async function hashSkills(rootDir: string): Promise<Map<string, SkillFiles>> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function diff(bundle: Map<string, SkillFiles>, disk: Map<string, SkillFiles>): SkillOp[] {
|
function diff(bundle: TargetSkills, disks: readonly TargetSkills[]): SkillOp[] {
|
||||||
const ops: SkillOp[] = [];
|
const ops: SkillOp[] = [];
|
||||||
for (const name of PASEO_SKILL_NAMES) {
|
for (const name of PASEO_SKILL_NAMES) {
|
||||||
const b = bundle.get(name);
|
const b = bundle.get(name);
|
||||||
const d = disk.get(name);
|
const targetFiles = disks.map((disk) => disk.get(name));
|
||||||
if (b && !d) ops.push({ kind: "add", name });
|
const installedTargets = targetFiles.filter(
|
||||||
else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name });
|
(files): files is SkillFiles => files !== undefined,
|
||||||
else if (!b && d) ops.push({ kind: "delete", name });
|
);
|
||||||
|
if (b) {
|
||||||
|
const missingTargets = installedTargets.length < disks.length;
|
||||||
|
const changedTargets = installedTargets.some((files) => !bundleFilesMatch(b, files));
|
||||||
|
if (missingTargets) ops.push({ kind: "add", name });
|
||||||
|
else if (changedTargets) ops.push({ kind: "update", name });
|
||||||
|
} else if (installedTargets.length > 0) {
|
||||||
|
ops.push({ kind: "delete", name });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ops.sort((a, b) => compareStrings(a.name, b.name));
|
ops.sort((a, b) => compareStrings(a.name, b.name));
|
||||||
return ops;
|
return ops;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filesEqual(a: SkillFiles, b: SkillFiles): boolean {
|
function hasInstalledPaseoSkill(disks: readonly TargetSkills[]): boolean {
|
||||||
if (a.size !== b.size) return false;
|
return disks.some((disk) => disk.size > 0);
|
||||||
for (const [rel, sha] of a) {
|
}
|
||||||
if (b.get(rel) !== sha) return false;
|
|
||||||
|
function bundleFilesMatch(bundle: SkillFiles, disk: SkillFiles): boolean {
|
||||||
|
for (const [rel, sha] of bundle) {
|
||||||
|
if (disk.get(rel) !== sha) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -107,16 +119,25 @@ function compareStrings(a: string, b: string): number {
|
|||||||
|
|
||||||
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
|
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
|
||||||
const t = targets ?? resolveSkillTargets();
|
const t = targets ?? resolveSkillTargets();
|
||||||
const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]);
|
const [bundle, agentsDisk, claudeDisk, codexDisk] = await Promise.all([
|
||||||
const ops = diff(bundle, disk);
|
hashSkills(t.sourceDir),
|
||||||
|
hashSkills(t.agentsDir),
|
||||||
|
hashSkills(t.claudeDir),
|
||||||
|
hashSkills(t.codexDir),
|
||||||
|
]);
|
||||||
|
const disks = [agentsDisk, claudeDisk, codexDisk];
|
||||||
|
const ops = diff(bundle, disks);
|
||||||
|
|
||||||
if (disk.size === 0) return { state: "not-installed", ops };
|
if (!hasInstalledPaseoSkill(disks)) return { state: "not-installed", ops };
|
||||||
if (ops.length === 0) return { state: "up-to-date", ops };
|
if (ops.length === 0) return { state: "up-to-date", ops };
|
||||||
return { state: "drift", ops };
|
return { state: "drift", ops };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applySkills(targets: SkillTargets): Promise<SkillsStatus> {
|
async function applySkills(
|
||||||
const status = await getSkillsStatus(targets);
|
targets: SkillTargets,
|
||||||
|
initialStatus?: SkillsStatus,
|
||||||
|
): Promise<SkillsStatus> {
|
||||||
|
const status = initialStatus ?? (await getSkillsStatus(targets));
|
||||||
|
|
||||||
const writes = status.ops
|
const writes = status.ops
|
||||||
.filter((op) => op.kind === "add" || op.kind === "update")
|
.filter((op) => op.kind === "add" || op.kind === "update")
|
||||||
@@ -151,6 +172,13 @@ export async function updateSkills(targets?: SkillTargets): Promise<SkillsStatus
|
|||||||
return applySkills(targets ?? resolveSkillTargets());
|
return applySkills(targets ?? resolveSkillTargets());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function autoUpdateInstalledSkills(targets?: SkillTargets): Promise<SkillsStatus> {
|
||||||
|
const t = targets ?? resolveSkillTargets();
|
||||||
|
const status = await getSkillsStatus(t);
|
||||||
|
if (status.state !== "drift") return status;
|
||||||
|
return applySkills(t, status);
|
||||||
|
}
|
||||||
|
|
||||||
export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
|
export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
|
||||||
const t = targets ?? resolveSkillTargets();
|
const t = targets ?? resolveSkillTargets();
|
||||||
for (const name of PASEO_SKILL_NAMES) {
|
for (const name of PASEO_SKILL_NAMES) {
|
||||||
|
|||||||
@@ -137,12 +137,23 @@ describe("syncSkills", () => {
|
|||||||
expect(await fs.readFile(path.join(customSkill, "SKILL.md"), "utf-8")).toBe("user content");
|
expect(await fs.readFile(path.join(customSkill, "SKILL.md"), "utf-8")).toBe("user content");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not delete files on disk that are no longer in the bundle", async () => {
|
it("removes stale files previously written to managed skill dirs", async () => {
|
||||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
|
await writeBundleSkill(sandbox.sourceDir, "paseo", {
|
||||||
|
"SKILL.md": "old",
|
||||||
|
"references/stale.md": "stale",
|
||||||
|
});
|
||||||
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
|
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
|
||||||
await fs.mkdir(path.join(onDiskSkill, "references"), { recursive: true });
|
|
||||||
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old");
|
await syncSkills({
|
||||||
await fs.writeFile(path.join(onDiskSkill, "references", "stale.md"), "stale");
|
sourceDir: sandbox.sourceDir,
|
||||||
|
agentsDir: sandbox.agentsDir,
|
||||||
|
claudeDir: sandbox.claudeDir,
|
||||||
|
codexDir: sandbox.codexDir,
|
||||||
|
skillNames: ["paseo"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs.rm(path.join(sandbox.sourceDir, "paseo"), { recursive: true, force: true });
|
||||||
|
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
|
||||||
|
|
||||||
await syncSkills({
|
await syncSkills({
|
||||||
sourceDir: sandbox.sourceDir,
|
sourceDir: sandbox.sourceDir,
|
||||||
@@ -153,8 +164,32 @@ describe("syncSkills", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("new");
|
expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("new");
|
||||||
expect(await fs.readFile(path.join(onDiskSkill, "references", "stale.md"), "utf-8")).toBe(
|
await expect(fs.access(path.join(onDiskSkill, "references", "stale.md"))).rejects.toThrow();
|
||||||
"stale",
|
await expect(fs.access(path.join(onDiskSkill, "references"))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves user-added files in managed skill dirs", async () => {
|
||||||
|
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
|
||||||
|
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
|
||||||
|
await fs.mkdir(path.join(onDiskSkill, "references"), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old");
|
||||||
|
await fs.writeFile(path.join(onDiskSkill, "my-context.md"), "user context");
|
||||||
|
await fs.writeFile(path.join(onDiskSkill, "references", "notes.md"), "user notes");
|
||||||
|
|
||||||
|
await syncSkills({
|
||||||
|
sourceDir: sandbox.sourceDir,
|
||||||
|
agentsDir: sandbox.agentsDir,
|
||||||
|
claudeDir: sandbox.claudeDir,
|
||||||
|
codexDir: sandbox.codexDir,
|
||||||
|
skillNames: ["paseo"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("new");
|
||||||
|
expect(await fs.readFile(path.join(onDiskSkill, "my-context.md"), "utf-8")).toBe(
|
||||||
|
"user context",
|
||||||
|
);
|
||||||
|
expect(await fs.readFile(path.join(onDiskSkill, "references", "notes.md"), "utf-8")).toBe(
|
||||||
|
"user notes",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -15,6 +16,13 @@ export interface SkillSyncResult {
|
|||||||
processedSkills: number;
|
processedSkills: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MANAGED_FILES_MANIFEST = ".paseo-managed-files.json";
|
||||||
|
|
||||||
|
interface ManagedFilesManifest {
|
||||||
|
version: 1;
|
||||||
|
files: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boolean> {
|
async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boolean> {
|
||||||
const src = await fs.readFile(srcPath);
|
const src = await fs.readFile(srcPath);
|
||||||
const dst = await fs.readFile(dstPath).catch(() => null);
|
const dst = await fs.readFile(dstPath).catch(() => null);
|
||||||
@@ -30,10 +38,12 @@ export async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
|||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const full = path.join(dir, entry.name);
|
const full = path.join(dir, entry.name);
|
||||||
|
const rel = path.relative(rootDir, full);
|
||||||
|
if (rel === MANAGED_FILES_MANIFEST) continue;
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await walk(full);
|
await walk(full);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
out.push(path.relative(rootDir, full));
|
out.push(rel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,14 +51,89 @@ export async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readManagedFilesManifest(dstDir: string): Promise<ManagedFilesManifest | null> {
|
||||||
|
const raw = await fs
|
||||||
|
.readFile(path.join(dstDir, MANAGED_FILES_MANIFEST), "utf-8")
|
||||||
|
.catch(() => null);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = safeParseJson(raw) as Partial<ManagedFilesManifest> | null;
|
||||||
|
if (!parsed) return null;
|
||||||
|
if (parsed.version !== 1 || typeof parsed.files !== "object" || parsed.files === null)
|
||||||
|
return null;
|
||||||
|
return { version: 1, files: parsed.files as Record<string, string> };
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseJson(raw: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashFile(filePath: string): Promise<string> {
|
||||||
|
const buf = await fs.readFile(filePath);
|
||||||
|
return createHash("sha256").update(buf).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeManifestIfChanged(
|
||||||
|
dstDir: string,
|
||||||
|
files: Record<string, string>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const manifest: ManagedFilesManifest = { version: 1, files };
|
||||||
|
const next = `${JSON.stringify(manifest, null, 2)}\n`;
|
||||||
|
const manifestPath = path.join(dstDir, MANAGED_FILES_MANIFEST);
|
||||||
|
const current = await fs.readFile(manifestPath, "utf-8").catch(() => null);
|
||||||
|
if (current === next) return false;
|
||||||
|
await fs.mkdir(dstDir, { recursive: true });
|
||||||
|
await fs.writeFile(manifestPath, next);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneEmptyParentDirs(rootDir: string, rels: readonly string[]): Promise<void> {
|
||||||
|
const dirs = new Set<string>();
|
||||||
|
for (const rel of rels) {
|
||||||
|
let dir = path.dirname(rel);
|
||||||
|
while (dir !== ".") {
|
||||||
|
dirs.add(dir);
|
||||||
|
dir = path.dirname(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const deepestFirst = [...dirs].sort(
|
||||||
|
(a, b) => b.split(path.sep).length - a.split(path.sep).length,
|
||||||
|
);
|
||||||
|
for (const rel of deepestFirst) {
|
||||||
|
await fs.rmdir(path.join(rootDir, rel)).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<number> {
|
async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<number> {
|
||||||
const files = await listFilesRecursive(srcDir);
|
const files = await listFilesRecursive(srcDir);
|
||||||
|
const srcFileSet = new Set(files);
|
||||||
|
const srcHashes: Record<string, string> = {};
|
||||||
|
for (const rel of files) {
|
||||||
|
srcHashes[rel] = await hashFile(path.join(srcDir, rel));
|
||||||
|
}
|
||||||
|
const previousManifest = await readManagedFilesManifest(dstDir);
|
||||||
let changed = 0;
|
let changed = 0;
|
||||||
for (const rel of files) {
|
for (const rel of files) {
|
||||||
if (await writeFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) {
|
if (await writeFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) {
|
||||||
changed++;
|
changed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deletedRels: string[] = [];
|
||||||
|
for (const [rel, previousHash] of Object.entries(previousManifest?.files ?? {})) {
|
||||||
|
if (srcFileSet.has(rel)) continue;
|
||||||
|
const dstPath = path.join(dstDir, rel);
|
||||||
|
const currentHash = await hashFile(dstPath).catch(() => null);
|
||||||
|
if (currentHash !== previousHash) continue;
|
||||||
|
await fs.rm(dstPath, { force: true });
|
||||||
|
deletedRels.push(rel);
|
||||||
|
changed++;
|
||||||
|
}
|
||||||
|
await pruneEmptyParentDirs(dstDir, deletedRels);
|
||||||
|
if (await writeManifestIfChanged(dstDir, srcHashes)) changed++;
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
ensureNotificationCenterRegistration,
|
ensureNotificationCenterRegistration,
|
||||||
} from "./features/notifications.js";
|
} from "./features/notifications.js";
|
||||||
import { registerOpenerHandlers } from "./features/opener.js";
|
import { registerOpenerHandlers } from "./features/opener.js";
|
||||||
|
import { registerEditorTargetHandlers } from "./features/editor-targets.js";
|
||||||
import { setupApplicationMenu } from "./features/menu.js";
|
import { setupApplicationMenu } from "./features/menu.js";
|
||||||
import {
|
import {
|
||||||
getPaseoBrowserIdForWebContents,
|
getPaseoBrowserIdForWebContents,
|
||||||
@@ -63,6 +64,7 @@ import {
|
|||||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||||
} from "./daemon/quit-lifecycle.js";
|
} from "./daemon/quit-lifecycle.js";
|
||||||
import { runDesktopStartup } from "./desktop-startup.js";
|
import { runDesktopStartup } from "./desktop-startup.js";
|
||||||
|
import { autoUpdateInstalledSkills } from "./integrations/skills/index.js";
|
||||||
|
|
||||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||||
const APP_SCHEME = "paseo";
|
const APP_SCHEME = "paseo";
|
||||||
@@ -697,6 +699,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
registerDialogHandlers();
|
registerDialogHandlers();
|
||||||
registerNotificationHandlers();
|
registerNotificationHandlers();
|
||||||
registerOpenerHandlers();
|
registerOpenerHandlers();
|
||||||
|
registerEditorTargetHandlers();
|
||||||
|
|
||||||
await createMainWindow();
|
await createMainWindow();
|
||||||
|
|
||||||
@@ -712,6 +715,11 @@ void runDesktopStartup({
|
|||||||
runCliPassthroughIfRequested,
|
runCliPassthroughIfRequested,
|
||||||
inheritLoginShellEnv,
|
inheritLoginShellEnv,
|
||||||
bootstrapGui: bootstrap,
|
bootstrapGui: bootstrap,
|
||||||
|
autoUpdateInstalledSkills: () => {
|
||||||
|
void autoUpdateInstalledSkills().catch((error) => {
|
||||||
|
log.error("[skills] auto-update failed", error);
|
||||||
|
});
|
||||||
|
},
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||||
process.stderr.write(`${message}\n`);
|
process.stderr.write(`${message}\n`);
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
|||||||
opener: {
|
opener: {
|
||||||
openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url),
|
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: {
|
webUtils: {
|
||||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ export const MutableDaemonConfigPatchSchema = z
|
|||||||
|
|
||||||
export type MutableDaemonConfig = z.infer<typeof MutableDaemonConfigSchema>;
|
export type MutableDaemonConfig = z.infer<typeof MutableDaemonConfigSchema>;
|
||||||
export type MutableDaemonConfigPatch = z.infer<typeof MutableDaemonConfigPatchSchema>;
|
export type MutableDaemonConfigPatch = z.infer<typeof MutableDaemonConfigPatchSchema>;
|
||||||
import type { LiteralUnion } from "./literal-union.js";
|
|
||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
@@ -1569,47 +1568,18 @@ export const WorkspaceSetupStatusRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO(2026-07): Remove once most clients are on >=0.1.50 and support arbitrary editor ids.
|
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
|
||||||
export const LEGACY_EDITOR_TARGET_IDS = [
|
export const LegacyListAvailableEditorsRequestSchema = z.object({
|
||||||
"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({
|
|
||||||
type: z.literal("list_available_editors_request"),
|
type: z.literal("list_available_editors_request"),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const OpenInEditorRequestSchema = z.object({
|
export const LegacyOpenInEditorRequestSchema = z.object({
|
||||||
type: z.literal("open_in_editor_request"),
|
type: z.literal("open_in_editor_request"),
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
editorId: EditorTargetIdSchema,
|
editorId: z.string().trim().min(1),
|
||||||
|
mode: z.enum(["open", "reveal"]).optional(),
|
||||||
|
cwd: z.string().optional(),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1924,8 +1894,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
PaseoWorktreeArchiveRequestSchema,
|
PaseoWorktreeArchiveRequestSchema,
|
||||||
CreatePaseoWorktreeRequestSchema,
|
CreatePaseoWorktreeRequestSchema,
|
||||||
WorkspaceSetupStatusRequestSchema,
|
WorkspaceSetupStatusRequestSchema,
|
||||||
ListAvailableEditorsRequestSchema,
|
LegacyListAvailableEditorsRequestSchema,
|
||||||
OpenInEditorRequestSchema,
|
LegacyOpenInEditorRequestSchema,
|
||||||
OpenProjectRequestSchema,
|
OpenProjectRequestSchema,
|
||||||
ArchiveWorkspaceRequestSchema,
|
ArchiveWorkspaceRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
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"),
|
type: z.literal("list_available_editors_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
requestId: z.string(),
|
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(),
|
error: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const OpenInEditorResponseMessageSchema = z.object({
|
export const LegacyOpenInEditorResponseMessageSchema = z.object({
|
||||||
type: z.literal("open_in_editor_response"),
|
type: z.literal("open_in_editor_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
@@ -3684,8 +3660,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
FetchWorkspacesResponseMessageSchema,
|
FetchWorkspacesResponseMessageSchema,
|
||||||
OpenProjectResponseMessageSchema,
|
OpenProjectResponseMessageSchema,
|
||||||
StartWorkspaceScriptResponseMessageSchema,
|
StartWorkspaceScriptResponseMessageSchema,
|
||||||
ListAvailableEditorsResponseMessageSchema,
|
LegacyListAvailableEditorsResponseMessageSchema,
|
||||||
OpenInEditorResponseMessageSchema,
|
LegacyOpenInEditorResponseMessageSchema,
|
||||||
ArchiveWorkspaceResponseMessageSchema,
|
ArchiveWorkspaceResponseMessageSchema,
|
||||||
FetchAgentResponseMessageSchema,
|
FetchAgentResponseMessageSchema,
|
||||||
FetchAgentTimelineResponseMessageSchema,
|
FetchAgentTimelineResponseMessageSchema,
|
||||||
@@ -3811,10 +3787,6 @@ export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPaylo
|
|||||||
export type WorkspaceScriptLifecycle = z.infer<typeof WorkspaceScriptLifecycleSchema>;
|
export type WorkspaceScriptLifecycle = z.infer<typeof WorkspaceScriptLifecycleSchema>;
|
||||||
export type WorkspaceScriptHealth = z.infer<typeof WorkspaceScriptHealthSchema>;
|
export type WorkspaceScriptHealth = z.infer<typeof WorkspaceScriptHealthSchema>;
|
||||||
export type WorkspaceScriptPayload = z.infer<typeof WorkspaceScriptPayloadSchema>;
|
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 FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
|
||||||
export type FetchAgentHistoryResponseMessage = z.infer<
|
export type FetchAgentHistoryResponseMessage = z.infer<
|
||||||
typeof FetchAgentHistoryResponseMessageSchema
|
typeof FetchAgentHistoryResponseMessageSchema
|
||||||
@@ -3828,10 +3800,12 @@ export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessa
|
|||||||
export type StartWorkspaceScriptResponseMessage = z.infer<
|
export type StartWorkspaceScriptResponseMessage = z.infer<
|
||||||
typeof StartWorkspaceScriptResponseMessageSchema
|
typeof StartWorkspaceScriptResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
export type ListAvailableEditorsResponseMessage = z.infer<
|
export type LegacyListAvailableEditorsResponseMessage = z.infer<
|
||||||
typeof ListAvailableEditorsResponseMessageSchema
|
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 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<
|
||||||
@@ -4030,8 +4004,10 @@ export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponse
|
|||||||
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 WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
|
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
|
||||||
export type ListAvailableEditorsRequest = z.infer<typeof ListAvailableEditorsRequestSchema>;
|
export type LegacyListAvailableEditorsRequest = z.infer<
|
||||||
export type OpenInEditorRequest = z.infer<typeof OpenInEditorRequestSchema>;
|
typeof LegacyListAvailableEditorsRequestSchema
|
||||||
|
>;
|
||||||
|
export type LegacyOpenInEditorRequest = z.infer<typeof LegacyOpenInEditorRequestSchema>;
|
||||||
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>;
|
||||||
|
|||||||
@@ -241,62 +241,42 @@ describe("workspace message schemas", () => {
|
|||||||
expect(parsed.type).toBe("open_project_request");
|
expect(parsed.type).toBe("open_project_request");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses list_available_editors_request", () => {
|
test("parses legacy editor RPC messages for compatibility", () => {
|
||||||
const parsed = SessionInboundMessageSchema.parse({
|
const listRequest = SessionInboundMessageSchema.parse({
|
||||||
type: "list_available_editors_request",
|
type: "list_available_editors_request",
|
||||||
requestId: "req-editors",
|
requestId: "req-editors",
|
||||||
});
|
});
|
||||||
|
const openRequest = SessionInboundMessageSchema.parse({
|
||||||
expect(parsed.type).toBe("list_available_editors_request");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses open_in_editor_request with flexible editor ids", () => {
|
|
||||||
const knownEditor = SessionInboundMessageSchema.parse({
|
|
||||||
type: "open_in_editor_request",
|
type: "open_in_editor_request",
|
||||||
requestId: "req-open-webstorm",
|
requestId: "req-open-editor",
|
||||||
editorId: "webstorm",
|
|
||||||
path: "/tmp/repo",
|
|
||||||
});
|
|
||||||
const unknownEditor = SessionInboundMessageSchema.parse({
|
|
||||||
type: "open_in_editor_request",
|
|
||||||
requestId: "req-open-custom",
|
|
||||||
editorId: "unknown-editor",
|
editorId: "unknown-editor",
|
||||||
path: "/tmp/repo",
|
path: "/tmp/repo",
|
||||||
|
mode: "reveal",
|
||||||
|
cwd: "/tmp",
|
||||||
});
|
});
|
||||||
|
const listResponse = SessionOutboundMessageSchema.parse({
|
||||||
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({
|
|
||||||
type: "list_available_editors_response",
|
type: "list_available_editors_response",
|
||||||
payload: {
|
payload: {
|
||||||
requestId: "req-editors",
|
requestId: "req-editors",
|
||||||
editors: [
|
editors: [{ id: "unknown-editor", label: "Unknown Editor" }],
|
||||||
{ id: "cursor", label: "Cursor" },
|
|
||||||
{ id: "unknown-editor", label: "Unknown Editor" },
|
|
||||||
],
|
|
||||||
error: null,
|
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({
|
const result = SessionInboundMessageSchema.safeParse({
|
||||||
type: "open_in_editor_request",
|
type: "open_in_editor_request",
|
||||||
requestId: "req-open-empty",
|
requestId: "req-open-empty",
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ function shouldEmitPlanApprovalPrompt(prompt: AgentPromptInput): boolean {
|
|||||||
return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt));
|
return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldEmitQuestionPrompt(prompt: AgentPromptInput): boolean {
|
||||||
|
return /emit\s+(?:a\s+)?synthetic\s+questions?/i.test(promptToText(prompt));
|
||||||
|
}
|
||||||
|
|
||||||
function resolveModelProfile(modelId: string | null | undefined): {
|
function resolveModelProfile(modelId: string | null | undefined): {
|
||||||
modelId: string;
|
modelId: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
@@ -524,6 +528,8 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
const stress = parseAgentStreamStressPrompt(prompt);
|
const stress = parseAgentStreamStressPrompt(prompt);
|
||||||
if (shouldEmitPlanApprovalPrompt(prompt)) {
|
if (shouldEmitPlanApprovalPrompt(prompt)) {
|
||||||
this.schedulePlanApprovalTurn(turn);
|
this.schedulePlanApprovalTurn(turn);
|
||||||
|
} else if (shouldEmitQuestionPrompt(prompt)) {
|
||||||
|
this.scheduleQuestionPromptTurn(turn);
|
||||||
} else if (largePayload) {
|
} else if (largePayload) {
|
||||||
this.scheduleLargePayloadTurn(turn, largePayload);
|
this.scheduleLargePayloadTurn(turn, largePayload);
|
||||||
} else if (stress) {
|
} else if (stress) {
|
||||||
@@ -576,9 +582,11 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
response: AgentPermissionResponse,
|
response: AgentPermissionResponse,
|
||||||
): Promise<AgentPermissionResult | void> {
|
): Promise<AgentPermissionResult | void> {
|
||||||
if (!this.pendingPermissions.delete(requestId)) {
|
const request = this.pendingPermissions.get(requestId);
|
||||||
|
if (!request) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
this.pendingPermissions.delete(requestId);
|
||||||
|
|
||||||
const turn = this.activeTurn;
|
const turn = this.activeTurn;
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -590,7 +598,12 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (turn) {
|
if (turn) {
|
||||||
this.finishTurnWithText(turn, "Synthetic plan approval resolved");
|
this.finishTurnWithText(
|
||||||
|
turn,
|
||||||
|
request.kind === "question"
|
||||||
|
? "Synthetic questions resolved"
|
||||||
|
: "Synthetic plan approval resolved",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -689,6 +702,13 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
turn.timer.unref?.();
|
turn.timer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleQuestionPromptTurn(turn: ActiveTurn): void {
|
||||||
|
turn.timer = setTimeout(() => {
|
||||||
|
this.emitQuestionPromptTurn(turn);
|
||||||
|
}, 0);
|
||||||
|
turn.timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
private emitPlanApprovalTurn(turn: ActiveTurn): void {
|
private emitPlanApprovalTurn(turn: ActiveTurn): void {
|
||||||
if (this.activeTurn !== turn) {
|
if (this.activeTurn !== turn) {
|
||||||
return;
|
return;
|
||||||
@@ -741,6 +761,61 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitQuestionPromptTurn(turn: ActiveTurn): void {
|
||||||
|
if (this.activeTurn !== turn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearTurnTimer(turn);
|
||||||
|
this.emit({
|
||||||
|
type: "turn_started",
|
||||||
|
provider: this.provider,
|
||||||
|
turnId: turn.turnId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request: AgentPermissionRequest = {
|
||||||
|
id: `mock-questions-${turn.turnId}`,
|
||||||
|
provider: this.provider,
|
||||||
|
name: "MockQuestions",
|
||||||
|
kind: "question",
|
||||||
|
title: "Questions",
|
||||||
|
input: {
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
question: "Which surface should this apply to?",
|
||||||
|
header: "surface",
|
||||||
|
options: [{ label: "App" }, { label: "Desktop" }],
|
||||||
|
multiSelect: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Which rollout should we use?",
|
||||||
|
header: "rollout",
|
||||||
|
options: [{ label: "Immediately" }, { label: "Behind feature flag" }],
|
||||||
|
multiSelect: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "What success criteria should we use?",
|
||||||
|
header: "success",
|
||||||
|
options: [],
|
||||||
|
multiSelect: false,
|
||||||
|
placeholder: "Describe success...",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
source: "mock_questions",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pendingPermissions.set(request.id, request);
|
||||||
|
this.emit({
|
||||||
|
type: "permission_requested",
|
||||||
|
provider: this.provider,
|
||||||
|
request,
|
||||||
|
turnId: turn.turnId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private emitStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void {
|
private emitStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void {
|
||||||
if (this.activeTurn !== turn) {
|
if (this.activeTurn !== turn) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import equal from "fast-deep-equal";
|
import equal from "fast-deep-equal";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { TTLCache } from "@isaacs/ttlcache";
|
|
||||||
import pMemoize from "p-memoize";
|
|
||||||
import { realpathSync } from "node:fs";
|
import { realpathSync } from "node:fs";
|
||||||
import type { FSWatcher } from "node:fs";
|
import type { FSWatcher } from "node:fs";
|
||||||
import { basename, resolve, sep } from "path";
|
import { basename, resolve, sep } from "path";
|
||||||
@@ -10,7 +8,6 @@ import { z } from "zod";
|
|||||||
import type { ToolSet } from "ai";
|
import type { ToolSet } from "ai";
|
||||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||||
import {
|
import {
|
||||||
isLegacyEditorTargetId,
|
|
||||||
serializeAgentStreamEvent,
|
serializeAgentStreamEvent,
|
||||||
type AgentSnapshotPayload,
|
type AgentSnapshotPayload,
|
||||||
type AgentAttachment,
|
type AgentAttachment,
|
||||||
@@ -26,8 +23,6 @@ import {
|
|||||||
type SubscribeCheckoutDiffRequest,
|
type SubscribeCheckoutDiffRequest,
|
||||||
type UnsubscribeCheckoutDiffRequest,
|
type UnsubscribeCheckoutDiffRequest,
|
||||||
type DirectorySuggestionsRequest,
|
type DirectorySuggestionsRequest,
|
||||||
type EditorTargetDescriptorPayload,
|
|
||||||
type EditorTargetId,
|
|
||||||
type ProjectPlacementPayload,
|
type ProjectPlacementPayload,
|
||||||
type WorkspaceSetupSnapshot,
|
type WorkspaceSetupSnapshot,
|
||||||
type WorkspaceDescriptorPayload,
|
type WorkspaceDescriptorPayload,
|
||||||
@@ -47,7 +42,6 @@ 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 { getPidLockInfo } from "./pid-lock.js";
|
import { getPidLockInfo } from "./pid-lock.js";
|
||||||
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||||
import {
|
import {
|
||||||
@@ -337,7 +331,6 @@ const LEGACY_MODE_ICONS = new Set<string>([
|
|||||||
"ShieldQuestionMark",
|
"ShieldQuestionMark",
|
||||||
]);
|
]);
|
||||||
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
|
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
|
||||||
const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50";
|
|
||||||
|
|
||||||
function errorToFriendlyMessage(error: unknown): string {
|
function errorToFriendlyMessage(error: unknown): string {
|
||||||
if (error instanceof Error) return error.message;
|
if (error instanceof Error) return error.message;
|
||||||
@@ -418,10 +411,6 @@ function clientSupportsAllProviders(appVersion: string | null): boolean {
|
|||||||
return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS);
|
return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean {
|
|
||||||
return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeleteFencedAgentStorage = AgentStorage & {
|
type DeleteFencedAgentStorage = AgentStorage & {
|
||||||
beginDelete(agentId: string): void;
|
beginDelete(agentId: string): void;
|
||||||
};
|
};
|
||||||
@@ -529,9 +518,6 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round(
|
|||||||
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS,
|
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS,
|
||||||
);
|
);
|
||||||
const AgentIdSchema = z.string().uuid();
|
const AgentIdSchema = z.string().uuid();
|
||||||
const AVAILABLE_EDITOR_TARGETS_CACHE_TTL_MS = 60_000;
|
|
||||||
const AVAILABLE_EDITOR_TARGETS_CACHE_KEY = "available";
|
|
||||||
|
|
||||||
interface VoiceModeBaseConfig {
|
interface VoiceModeBaseConfig {
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
}
|
}
|
||||||
@@ -817,21 +803,6 @@ export class Session {
|
|||||||
private readonly terminalController: TerminalSessionController;
|
private readonly terminalController: TerminalSessionController;
|
||||||
private inflightRequests = 0;
|
private inflightRequests = 0;
|
||||||
private peakInflightRequests = 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 checkoutDiffSubscriptions = new Map<string, () => void>();
|
||||||
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||||
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
|
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
|
||||||
@@ -1446,15 +1417,6 @@ export class Session {
|
|||||||
return LEGACY_PROVIDER_IDS.has(provider);
|
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(
|
private agentThinkingOptionMatchesFilter(
|
||||||
agent: AgentSnapshotPayload,
|
agent: AgentSnapshotPayload,
|
||||||
filter: AgentUpdatesFilter,
|
filter: AgentUpdatesFilter,
|
||||||
@@ -2128,10 +2090,11 @@ export class Session {
|
|||||||
return this.handleCreatePaseoWorktreeRequest(msg);
|
return this.handleCreatePaseoWorktreeRequest(msg);
|
||||||
case "workspace_setup_status_request":
|
case "workspace_setup_status_request":
|
||||||
return this.handleWorkspaceSetupStatusRequest(msg);
|
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":
|
case "list_available_editors_request":
|
||||||
return this.handleListAvailableEditorsRequest(msg);
|
return this.handleLegacyListAvailableEditorsRequest(msg);
|
||||||
case "open_in_editor_request":
|
case "open_in_editor_request":
|
||||||
return this.handleOpenInEditorRequest(msg);
|
return this.handleLegacyOpenInEditorRequest(msg);
|
||||||
case "open_project_request":
|
case "open_project_request":
|
||||||
return this.handleOpenProjectRequest(msg);
|
return this.handleOpenProjectRequest(msg);
|
||||||
case "archive_workspace_request":
|
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(
|
private async handleStartWorkspaceScriptRequest(
|
||||||
request: StartWorkspaceScriptRequest,
|
request: StartWorkspaceScriptRequest,
|
||||||
): Promise<void> {
|
): 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" }>,
|
request: Extract<SessionInboundMessage, { type: "list_available_editors_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
this.emit({
|
||||||
const editors = await this.getAvailableEditorTargets();
|
type: "list_available_editors_response",
|
||||||
this.emit({
|
payload: {
|
||||||
type: "list_available_editors_response",
|
requestId: request.requestId,
|
||||||
payload: {
|
editors: [],
|
||||||
requestId: request.requestId,
|
error: "Editor opening moved to the desktop app and is no longer supported by the daemon",
|
||||||
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(
|
private async handleLegacyOpenInEditorRequest(
|
||||||
request: Extract<SessionInboundMessage, { type: "open_in_editor_request" }>,
|
request: Extract<SessionInboundMessage, { type: "open_in_editor_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
this.emit({
|
||||||
await this.openEditorTarget({ editorId: request.editorId, path: request.path });
|
type: "open_in_editor_response",
|
||||||
this.emit({
|
payload: {
|
||||||
type: "open_in_editor_response",
|
requestId: request.requestId,
|
||||||
payload: {
|
error: "Editor opening moved to the desktop app and is no longer supported by the daemon",
|
||||||
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(
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import path from "node:path";
|
|||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Session } from "./session.js";
|
import { Session } from "./session.js";
|
||||||
import type {
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
AgentSnapshotPayload,
|
|
||||||
EditorTargetDescriptorPayload,
|
|
||||||
SessionOutboundMessage,
|
|
||||||
} from "@getpaseo/protocol/messages";
|
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import { AgentStorage } from "./agent/agent-storage.js";
|
import { AgentStorage } from "./agent/agent-storage.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -123,10 +119,6 @@ interface SessionTestAccess {
|
|||||||
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
|
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
|
||||||
emit(message: unknown): void;
|
emit(message: unknown): void;
|
||||||
onMessage(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;
|
paseoHome: string;
|
||||||
terminalManager: {
|
terminalManager: {
|
||||||
killTerminal(id: string): unknown;
|
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);
|
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 emitted: SessionOutboundMessage[] = [];
|
||||||
const session = createSessionForWorkspaceTests({ appVersion: "0.1.50" });
|
const session = createSessionForWorkspaceTests({
|
||||||
|
onMessage: (message) => emitted.push(message),
|
||||||
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" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await session.handleMessage({
|
await session.handleMessage({
|
||||||
type: "list_available_editors_request",
|
type: "list_available_editors_request",
|
||||||
requestId: "req-editors",
|
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({
|
await session.handleMessage({
|
||||||
type: "open_in_editor_request",
|
type: "open_in_editor_request",
|
||||||
requestId: "req-open-editor",
|
requestId: "req-open-editor",
|
||||||
@@ -3630,11 +3501,15 @@ test("open_in_editor_request launches the selected target", async () => {
|
|||||||
path: REPO_CWD,
|
path: REPO_CWD,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(calls).toEqual([{ editorId: "vscode", path: REPO_CWD }]);
|
const listResponse = findByType(emitted, "list_available_editors_response");
|
||||||
const response = emitted.find((message) => message.type === "open_in_editor_response") as
|
const openResponse = findByType(emitted, "open_in_editor_response");
|
||||||
| { payload: Record<string, unknown> }
|
expect(listResponse?.payload.editors).toEqual([]);
|
||||||
| undefined;
|
expect(listResponse?.payload.error).toBe(
|
||||||
expect(response?.payload.error).toBeNull();
|
"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 () => {
|
test("archive_workspace_request hides non-destructive workspace records", async () => {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ Two ways to install:
|
|||||||
- **Desktop app:** Settings → Integrations → Install
|
- **Desktop app:** Settings → Integrations → Install
|
||||||
- **Manual:** `npx skills add getpaseo/paseo`, this installs to `~/.agents/skills/` and sets up symlinks for each agent.
|
- **Manual:** `npx skills add getpaseo/paseo`, this installs to `~/.agents/skills/` and sets up symlinks for each agent.
|
||||||
|
|
||||||
|
When the desktop app finds installed Paseo skills, it keeps the bundled skills up to date on startup. If automatic update fails, use Settings → Integrations → Update or the manual command above.
|
||||||
|
|
||||||
## `/paseo`, Paseo Reference
|
## `/paseo`, Paseo Reference
|
||||||
|
|
||||||
The foundational skill. Paseo reference for managing agents and worktrees. Load it when an agent needs to create agents, send them prompts, or manage worktrees.
|
The foundational skill. Paseo reference for managing agents and worktrees. Load it when an agent needs to create agents, send them prompts, or manage worktrees.
|
||||||
|
|||||||
Reference in New Issue
Block a user