diff --git a/packages/app/src/components/worktree-setup-callout-policy.test.ts b/packages/app/src/components/worktree-setup-callout-policy.test.ts new file mode 100644 index 000000000..03257be24 --- /dev/null +++ b/packages/app/src/components/worktree-setup-callout-policy.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + buildWorktreeSetupCalloutPolicy, + selectActiveGitWorkspaceProject, + shouldShowWorktreeSetupCallout, + type WorktreeSetupWorkspaceInput, +} from "./worktree-setup-callout-policy"; + +function gitWorkspace( + overrides: Partial = {}, +): WorktreeSetupWorkspaceInput { + return { + projectId: "project-1", + projectKind: "git", + projectRootPath: "/repo/project-1", + project: { checkout: { mainRepoRoot: "/repo/main-project-1" } }, + ...overrides, + }; +} + +describe("selectActiveGitWorkspaceProject", () => { + it("selects the active git workspace project from checkout metadata", () => { + expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({ + serverId: "server-1", + projectKey: "project-1", + repoRoot: "/repo/main-project-1", + }); + }); + + it("falls back to the workspace project root when checkout metadata has no main root", () => { + expect( + selectActiveGitWorkspaceProject( + "server-1", + gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }), + ), + ).toEqual({ + serverId: "server-1", + projectKey: "project-1", + repoRoot: "/repo/project-1", + }); + }); + + it("ignores non-git workspaces and blank project coordinates", () => { + expect( + selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })), + ).toBe(null); + expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe( + null, + ); + expect( + selectActiveGitWorkspaceProject( + "server-1", + gitWorkspace({ projectRootPath: " ", project: null }), + ), + ).toBe(null); + }); +}); + +describe("shouldShowWorktreeSetupCallout", () => { + it("shows the callout when paseo config was read and setup commands are missing", () => { + expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true); + expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true); + }); + + it("does not show the callout when setup commands are present", () => { + expect( + shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }), + ).toBe(false); + expect( + shouldShowWorktreeSetupCallout({ + ok: true, + config: { worktree: { setup: [" ", "npm install"] } }, + }), + ).toBe(false); + }); + + it("does not show the callout when reading paseo config fails or has not completed", () => { + expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false); + expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false); + }); +}); + +describe("buildWorktreeSetupCalloutPolicy", () => { + it("builds the stable sidebar callout identity and action route", () => { + expect( + buildWorktreeSetupCalloutPolicy({ + serverId: "server-1", + projectKey: "project-1", + repoRoot: "/repo/project-1", + }), + ).toEqual({ + id: "worktree-setup-missing:project-1", + dismissalKey: "worktree-setup-missing:project-1", + priority: 100, + title: "Set up worktree scripts", + description: + "Add setup commands so new worktrees can install dependencies and prepare themselves automatically.", + actionLabel: "Open project settings", + projectSettingsRoute: "/settings/projects/project-1", + testID: "worktree-setup-callout-project-1", + }); + }); +}); diff --git a/packages/app/src/components/worktree-setup-callout-policy.ts b/packages/app/src/components/worktree-setup-callout-policy.ts new file mode 100644 index 000000000..fc3dd6211 --- /dev/null +++ b/packages/app/src/components/worktree-setup-callout-policy.ts @@ -0,0 +1,85 @@ +import type { PaseoConfigRaw } from "@server/shared/messages"; +import { buildProjectSettingsRoute } from "@/utils/host-routes"; + +export interface WorktreeSetupWorkspaceInput { + projectId: string; + projectKind: string; + projectRootPath: string; + project?: { + checkout?: { + mainRepoRoot?: string | null; + } | null; + } | null; +} + +export interface ActiveGitWorkspaceProject { + serverId: string; + projectKey: string; + repoRoot: string; +} + +interface ReadProjectConfigResult { + ok: boolean; + config?: PaseoConfigRaw | null; +} + +export interface WorktreeSetupCalloutPolicy { + id: string; + dismissalKey: string; + priority: number; + title: string; + description: string; + actionLabel: string; + projectSettingsRoute: string; + testID: string; +} + +export function selectActiveGitWorkspaceProject( + serverId: string, + workspace: WorktreeSetupWorkspaceInput, +): ActiveGitWorkspaceProject | null { + if (workspace.projectKind !== "git") { + return null; + } + + const projectKey = workspace.projectId.trim(); + const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim(); + if (!projectKey || !repoRoot) { + return null; + } + + return { serverId, projectKey, repoRoot }; +} + +export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) { + return readResult?.ok === true && !hasSetupCommands(readResult.config ?? {}); +} + +export function buildWorktreeSetupCalloutPolicy( + project: ActiveGitWorkspaceProject, +): WorktreeSetupCalloutPolicy { + const calloutKey = `worktree-setup-missing:${project.projectKey}`; + + return { + id: calloutKey, + dismissalKey: calloutKey, + priority: 100, + title: "Set up worktree scripts", + description: + "Add setup commands so new worktrees can install dependencies and prepare themselves automatically.", + actionLabel: "Open project settings", + projectSettingsRoute: buildProjectSettingsRoute(project.projectKey), + testID: `worktree-setup-callout-${project.projectKey}`, + }; +} + +function hasSetupCommands(config: PaseoConfigRaw): boolean { + const setup = config.worktree?.setup; + if (typeof setup === "string") { + return setup.trim().length > 0; + } + if (Array.isArray(setup)) { + return setup.some((command) => typeof command === "string" && command.trim().length > 0); + } + return false; +} diff --git a/packages/app/src/components/worktree-setup-callout-source.test.tsx b/packages/app/src/components/worktree-setup-callout-source.test.tsx deleted file mode 100644 index b2f1431fe..000000000 --- a/packages/app/src/components/worktree-setup-callout-source.test.tsx +++ /dev/null @@ -1,279 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context"; -import { SidebarCalloutSlot } from "./sidebar-callout-slot"; - -const { theme } = vi.hoisted(() => ({ - theme: { - spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, - borderWidth: { 1: 1 }, - borderRadius: { md: 6 }, - fontSize: { xs: 11, sm: 13 }, - fontWeight: { medium: "500", semibold: "600" }, - colors: { - surface0: "#000", - foreground: "#fff", - foregroundMuted: "#aaa", - border: "#555", - destructive: "#f44", - }, - }, -})); - -const asyncStorage = vi.hoisted(() => ({ - values: new Map(), - getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - asyncStorage.values.set(key, value); - }), -})); - -const router = vi.hoisted(() => ({ - navigate: vi.fn(), -})); - -const activeSelection = vi.hoisted(() => ({ - value: { serverId: "server-1", workspaceId: "workspace-1" } as { - serverId: string; - workspaceId: string; - } | null, -})); - -const activeWorkspace = vi.hoisted(() => ({ - value: { - id: "workspace-1", - projectId: "project-1", - projectKind: "git", - projectRootPath: "/repo/project-1", - project: { checkout: { mainRepoRoot: "/repo/project-1" } }, - } as Record | null, -})); - -const client = vi.hoisted(() => ({ - readProjectConfig: vi.fn(), -})); - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorage, -})); - -vi.mock("expo-router", () => ({ - useRouter: () => router, -})); - -vi.mock("@/stores/navigation-active-workspace-store", () => ({ - useActiveWorkspaceSelection: () => activeSelection.value, -})); - -vi.mock("@/stores/session-store-hooks", () => ({ - useWorkspaceFields: ( - serverId: string | null, - workspaceId: string | null, - project: (workspace: Record) => unknown, - ) => { - if ( - !activeWorkspace.value || - serverId !== activeSelection.value?.serverId || - workspaceId !== activeWorkspace.value.id - ) { - return null; - } - return project(activeWorkspace.value); - }, -})); - -vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: (serverId: string) => (serverId === "server-1" ? client : null), -})); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, - }, - useUnistyles: () => ({ theme }), -})); - -vi.mock("lucide-react-native", () => { - const X = (props: Record) => React.createElement("span", props); - return { X }; -}); - -vi.stubGlobal("React", React); -vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - -import { WorktreeSetupCalloutSource } from "./worktree-setup-callout-source"; - -function readOk(config: Record) { - return { - ok: true, - config, - revision: { exists: true, mtimeMs: 1, size: 2 }, - }; -} - -function readError() { - return { - ok: false, - error: { code: "project_not_found", message: "Project not found" }, - }; -} - -function Harness({ queryClient }: { queryClient: QueryClient }) { - return ( - - - - - - - ); -} - -async function renderHarness(root: Root, queryClient: QueryClient): Promise { - await act(async () => { - root.render(); - for (let index = 0; index < 5; index += 1) { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - }); -} - -async function findByTestId(testID: string): Promise { - let element: HTMLElement | null = null; - for (let index = 0; index < 10 && !element; index += 1) { - await act(async () => { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null; - } - return element; -} - -describe("WorktreeSetupCalloutSource", () => { - let root: Root | null = null; - let container: HTMLElement | null = null; - let queryClient: QueryClient | null = null; - - beforeEach(() => { - activeSelection.value = { serverId: "server-1", workspaceId: "workspace-1" }; - activeWorkspace.value = { - id: "workspace-1", - projectId: "project-1", - projectKind: "git", - projectRootPath: "/repo/project-1", - project: { checkout: { mainRepoRoot: "/repo/project-1" } }, - }; - client.readProjectConfig.mockReset(); - client.readProjectConfig.mockResolvedValue(readOk({})); - router.navigate.mockClear(); - asyncStorage.values.clear(); - asyncStorage.getItem.mockClear(); - asyncStorage.setItem.mockClear(); - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(async () => { - if (root) { - await act(async () => { - root?.unmount(); - await Promise.resolve(); - }); - } - queryClient?.clear(); - queryClient = null; - root = null; - container?.remove(); - container = null; - }); - - it("registers a callout for an active git workspace with missing setup", async () => { - await renderHarness(root!, queryClient!); - - expect(await findByTestId("worktree-setup-callout-project-1")).not.toBeNull(); - expect(container?.textContent).toContain("Set up worktree scripts"); - expect(container?.textContent).toContain("Open project settings"); - expect(client.readProjectConfig).toHaveBeenCalledWith("/repo/project-1"); - }); - - it("does not register a callout for a non-git workspace", async () => { - activeWorkspace.value = { - id: "workspace-1", - projectId: "project-1", - projectKind: "local", - projectRootPath: "/repo/project-1", - }; - - await renderHarness(root!, queryClient!); - - expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull(); - expect(client.readProjectConfig).not.toHaveBeenCalled(); - }); - - it("does not register a callout when setup is present", async () => { - client.readProjectConfig.mockResolvedValue(readOk({ worktree: { setup: "npm install" } })); - - await renderHarness(root!, queryClient!); - - expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull(); - }); - - it("does not register a callout without an active workspace", async () => { - activeSelection.value = null; - - await renderHarness(root!, queryClient!); - - expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull(); - expect(client.readProjectConfig).not.toHaveBeenCalled(); - }); - - it("does not register a callout when reading paseo.json fails", async () => { - client.readProjectConfig.mockResolvedValue(readError()); - - await renderHarness(root!, queryClient!); - - expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull(); - }); - - it("opens project settings from the callout action", async () => { - await renderHarness(root!, queryClient!); - - const action = await findByTestId("worktree-setup-callout-project-1-action-0"); - expect(action).not.toBeNull(); - - act(() => { - action?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - expect(router.navigate).toHaveBeenCalledWith("/settings/projects/project-1"); - }); - - it("persists dismissal for the project", async () => { - await renderHarness(root!, queryClient!); - - const dismiss = await findByTestId("worktree-setup-callout-project-1-dismiss"); - expect(dismiss).not.toBeNull(); - - act(() => { - dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - expect(asyncStorage.setItem).toHaveBeenCalledWith( - "@paseo:sidebar-callout-dismissals", - JSON.stringify(["worktree-setup-missing:project-1"]), - ); - expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull(); - }); -}); diff --git a/packages/app/src/components/worktree-setup-callout-source.tsx b/packages/app/src/components/worktree-setup-callout-source.tsx index 45fc49e0e..2cba8dfe6 100644 --- a/packages/app/src/components/worktree-setup-callout-source.tsx +++ b/packages/app/src/components/worktree-setup-callout-source.tsx @@ -1,48 +1,16 @@ import { useQuery } from "@tanstack/react-query"; -import type { PaseoConfigRaw } from "@server/shared/messages"; import { useRouter } from "expo-router"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; import { useStableEvent } from "@/hooks/use-stable-event"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { buildProjectSettingsRoute } from "@/utils/host-routes"; - -interface ActiveGitWorkspaceProject { - serverId: string; - projectKey: string; - repoRoot: string; -} - -function selectActiveGitWorkspaceProject( - serverId: string, - workspace: WorkspaceDescriptor, -): ActiveGitWorkspaceProject | null { - if (workspace.projectKind !== "git") { - return null; - } - - const projectKey = workspace.projectId.trim(); - const repoRoot = (workspace.project?.checkout.mainRepoRoot ?? workspace.projectRootPath).trim(); - if (!projectKey || !repoRoot) { - return null; - } - - return { serverId, projectKey, repoRoot }; -} - -function hasSetupCommands(config: PaseoConfigRaw): boolean { - const setup = config.worktree?.setup; - if (typeof setup === "string") { - return setup.trim().length > 0; - } - if (Array.isArray(setup)) { - return setup.some((command) => typeof command === "string" && command.trim().length > 0); - } - return false; -} +import { + buildWorktreeSetupCalloutPolicy, + selectActiveGitWorkspaceProject, + shouldShowWorktreeSetupCallout, +} from "./worktree-setup-callout-policy"; export function WorktreeSetupCalloutSource() { const selection = useActiveWorkspaceSelection(); @@ -58,7 +26,7 @@ export function WorktreeSetupCalloutSource() { if (!activeProject) { return; } - router.navigate(buildProjectSettingsRoute(activeProject.projectKey)); + router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute); }); const readQuery = useQuery({ @@ -73,29 +41,31 @@ export function WorktreeSetupCalloutSource() { retry: false, }); - const shouldShow = - activeProject !== null && - readQuery.data?.ok === true && - !hasSetupCommands(readQuery.data.config ?? {}); + const calloutPolicy = useMemo( + () => + activeProject && shouldShowWorktreeSetupCallout(readQuery.data) + ? buildWorktreeSetupCalloutPolicy(activeProject) + : null, + [activeProject, readQuery.data], + ); useEffect(() => { - if (!shouldShow || !activeProject) { + if (!calloutPolicy) { return; } return callouts.show({ - id: `worktree-setup-missing:${activeProject.projectKey}`, - dismissalKey: `worktree-setup-missing:${activeProject.projectKey}`, - priority: 100, - title: "Set up worktree scripts", - description: - "Add setup commands so new worktrees can install dependencies and prepare themselves automatically.", + id: calloutPolicy.id, + dismissalKey: calloutPolicy.dismissalKey, + priority: calloutPolicy.priority, + title: calloutPolicy.title, + description: calloutPolicy.description, actions: [ - { label: "Open project settings", onPress: openProjectSettings, variant: "primary" }, + { label: calloutPolicy.actionLabel, onPress: openProjectSettings, variant: "primary" }, ], - testID: `worktree-setup-callout-${activeProject.projectKey}`, + testID: calloutPolicy.testID, }); - }, [activeProject, callouts, openProjectSettings, shouldShow]); + }, [calloutPolicy, callouts, openProjectSettings]); return null; }