diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 3468de26f..15f96bb94 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -27,7 +27,7 @@ import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-nativ import { SafeAreaProvider } from "react-native-safe-area-context"; import { UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { CommandCenter } from "@/components/command-center"; -import { DaemonVersionMismatchCalloutSource } from "@/components/daemon-version-mismatch-callout-source"; +import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source"; import { DownloadToast } from "@/components/download-toast"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { LeftSidebar } from "@/components/left-sidebar"; @@ -470,7 +470,7 @@ function AppContainer({ {isCompactLayout && chromeEnabled && } - + ({ - 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 hostsState = vi.hoisted(() => ({ - value: [{ serverId: "server-1", label: "Local daemon" }], -})); - -const sessionState = vi.hoisted(() => ({ - value: { - sessions: { - "server-1": { - serverInfo: { - serverId: "server-1", - hostname: "MacBook", - version: "1.2.2", - }, - }, - }, - }, -})); - -const router = vi.hoisted(() => ({ - navigate: vi.fn(), -})); - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorage, -})); - -vi.mock("expo-router", () => ({ - useRouter: () => router, -})); - -vi.mock("@/runtime/host-runtime", () => ({ - useHosts: () => hostsState.value, -})); - -vi.mock("@/stores/session-store", () => ({ - useSessionStore: (selector: (state: typeof sessionState.value) => unknown) => - selector(sessionState.value), -})); - -vi.mock("@/utils/app-version", () => ({ - resolveAppVersion: () => "1.2.3", -})); - -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 { DaemonVersionMismatchCalloutSource } from "./daemon-version-mismatch-callout-source"; - -function Harness() { - return ( - - - - - ); -} - -async function renderHarness(root: Root): Promise { - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -describe("DaemonVersionMismatchCalloutSource", () => { - let root: Root | null = null; - let container: HTMLElement | null = null; - - beforeEach(() => { - hostsState.value = [{ serverId: "server-1", label: "Local daemon" }]; - sessionState.value = { - sessions: { - "server-1": { - serverInfo: { - serverId: "server-1", - hostname: "MacBook", - version: "1.2.2", - }, - }, - }, - }; - router.navigate.mockClear(); - asyncStorage.values.clear(); - asyncStorage.getItem.mockClear(); - asyncStorage.setItem.mockClear(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - }); - - it("registers a daemon mismatch sidebar callout", async () => { - await renderHarness(root!); - - expect( - container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'), - ).not.toBeNull(); - expect(container?.textContent).toContain("Daemon version mismatch"); - expect(container?.textContent).toContain("Local daemon is running v1.2.2"); - expect(container?.textContent).toContain("This app is v1.2.3"); - expect(container?.textContent).toContain( - "For the best experience, keep the daemon and client on the same version.", - ); - }); - - it("does not register a callout when versions match", async () => { - sessionState.value.sessions["server-1"]!.serverInfo!.version = "1.2.3"; - - await renderHarness(root!); - - expect( - container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'), - ).toBeNull(); - }); - - it("opens host settings from the callout action", async () => { - await renderHarness(root!); - - const action = container?.querySelector( - '[data-testid="daemon-version-mismatch-callout-server-1-action-0"]', - ) as HTMLElement | null; - expect(action).not.toBeNull(); - - act(() => { - action?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - expect(router.navigate).toHaveBeenCalledWith("/settings/hosts/server-1"); - }); - - it("persists dismissal for the current app-daemon version pair", async () => { - await renderHarness(root!); - - const dismiss = container?.querySelector( - '[data-testid="daemon-version-mismatch-callout-server-1-dismiss"]', - ) as HTMLElement | null; - expect(dismiss).not.toBeNull(); - - act(() => { - dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - expect(asyncStorage.setItem).toHaveBeenCalledWith( - "@paseo:sidebar-callout-dismissals", - JSON.stringify(["daemon-version-mismatch:server-1:1.2.3:1.2.2"]), - ); - expect( - container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'), - ).toBeNull(); - }); -}); diff --git a/packages/app/src/components/daemon-version-mismatch-callout-source.tsx b/packages/app/src/components/daemon-version-mismatch-callout-source.tsx deleted file mode 100644 index 276139206..000000000 --- a/packages/app/src/components/daemon-version-mismatch-callout-source.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useRouter } from "expo-router"; -import { useEffect, useMemo } from "react"; -import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; -import { formatVersionWithPrefix, isVersionMismatch } from "@/desktop/updates/desktop-updates"; -import { useStableEvent } from "@/hooks/use-stable-event"; -import { useHosts } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { resolveAppVersion } from "@/utils/app-version"; -import { buildSettingsHostRoute } from "@/utils/host-routes"; - -interface DaemonVersionMismatch { - serverId: string; - label: string; - appVersion: string; - daemonVersion: string; -} - -function useDaemonVersionMismatches(): DaemonVersionMismatch[] { - const hosts = useHosts(); - const sessions = useSessionStore((state) => state.sessions); - const appVersion = resolveAppVersion(); - - return useMemo(() => { - if (!appVersion) { - return []; - } - - return hosts - .map((entry) => ({ - serverId: entry.serverId, - label: entry.label?.trim() || sessions[entry.serverId]?.serverInfo?.hostname || "Daemon", - appVersion, - daemonVersion: sessions[entry.serverId]?.serverInfo?.version ?? null, - })) - .filter((entry): entry is DaemonVersionMismatch => - isVersionMismatch(entry.appVersion, entry.daemonVersion), - ); - }, [appVersion, hosts, sessions]); -} - -function DaemonVersionMismatchRegistration({ mismatch }: { mismatch: DaemonVersionMismatch }) { - const callouts = useSidebarCallouts(); - const router = useRouter(); - const { appVersion, daemonVersion, label, serverId } = mismatch; - const openSettings = useStableEvent(() => { - router.navigate(buildSettingsHostRoute(serverId)); - }); - - useEffect(() => { - return callouts.show({ - id: `daemon-version-mismatch:${serverId}`, - dismissalKey: `daemon-version-mismatch:${serverId}:${appVersion}:${daemonVersion}`, - priority: 300, - title: "Daemon version mismatch", - description: `${label} is running ${formatVersionWithPrefix( - daemonVersion, - )}. This app is ${formatVersionWithPrefix( - appVersion, - )}. For the best experience, keep the daemon and client on the same version.`, - variant: "error", - actions: [{ label: "Open settings", onPress: openSettings, variant: "primary" }], - testID: `daemon-version-mismatch-callout-${serverId}`, - }); - }, [appVersion, callouts, daemonVersion, label, openSettings, serverId]); - - return null; -} - -export function DaemonVersionMismatchCalloutSource() { - const mismatches = useDaemonVersionMismatches(); - - return ( - <> - {mismatches.map((mismatch) => ( - - ))} - - ); -} diff --git a/packages/app/src/components/worktree-setup-callout-source.test.tsx b/packages/app/src/components/worktree-setup-callout-source.test.tsx new file mode 100644 index 000000000..c3ac049bc --- /dev/null +++ b/packages/app/src/components/worktree-setup-callout-source.test.tsx @@ -0,0 +1,279 @@ +/** + * @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", () => ({ + useNavigationActiveWorkspaceSelection: () => 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 new file mode 100644 index 000000000..82305a1d1 --- /dev/null +++ b/packages/app/src/components/worktree-setup-callout-source.tsx @@ -0,0 +1,101 @@ +import { useQuery } from "@tanstack/react-query"; +import type { PaseoConfigRaw } from "@server/shared/messages"; +import { useRouter } from "expo-router"; +import { useEffect } from "react"; +import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useNavigationActiveWorkspaceSelection } 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; +} + +export function WorktreeSetupCalloutSource() { + const selection = useNavigationActiveWorkspaceSelection(); + const activeProject = useWorkspaceFields( + selection?.serverId ?? null, + selection?.workspaceId ?? null, + (workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace), + ); + const client = useHostRuntimeClient(activeProject?.serverId ?? ""); + const callouts = useSidebarCallouts(); + const router = useRouter(); + const openProjectSettings = useStableEvent(() => { + if (!activeProject) { + return; + } + router.navigate(buildProjectSettingsRoute(activeProject.projectKey)); + }); + + const readQuery = useQuery({ + queryKey: ["project-config", activeProject?.serverId ?? "", activeProject?.repoRoot ?? ""], + queryFn: () => { + if (!client || !activeProject) { + throw new Error("Project config query requires an active git workspace"); + } + return client.readProjectConfig(activeProject.repoRoot); + }, + enabled: Boolean(client && activeProject), + retry: false, + }); + + const shouldShow = + activeProject !== null && + readQuery.data?.ok === true && + !hasSetupCommands(readQuery.data.config ?? {}); + + useEffect(() => { + if (!shouldShow || !activeProject) { + 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.", + actions: [ + { label: "Open project settings", onPress: openProjectSettings, variant: "primary" }, + ], + testID: `worktree-setup-callout-${activeProject.projectKey}`, + }); + }, [activeProject, callouts, openProjectSettings, shouldShow]); + + return null; +}