diff --git a/docs/conductor-project-import.md b/docs/conductor-project-import.md deleted file mode 100644 index 2a01e4d70..000000000 --- a/docs/conductor-project-import.md +++ /dev/null @@ -1,34 +0,0 @@ -# Conductor Project Import - -Paseo can import repository-local Conductor project settings into `paseo.json`. - -The importer reads only these files from the selected project root: - -- `.conductor/settings.local.toml` -- `.conductor/settings.toml` -- `conductor.json`, only when `.conductor/settings.toml` is absent - -It does not read `~/.conductor` or managed organization settings, because importing those into a repository-owned `paseo.json` could leak local or policy-controlled values. - -## Imported - -- `scripts.setup` becomes `worktree.setup` when Paseo setup is empty. -- `scripts.archive` becomes `worktree.teardown` when Paseo teardown is empty. -- `scripts.run..command` becomes `scripts..command` unless Paseo already has that script id. -- Legacy `scripts.run` string becomes `scripts.run`. -- Run `args` are shell-quoted and appended to the command. -- Safe relative run `options.cwd` becomes a `cd -- &&` command prefix. -- Run scripts using `CONDUCTOR_PORT` become Paseo service scripts and use `PASEO_PORT`. - -## Reported But Not Imported - -- Existing Paseo setup, teardown, or script ids win and are reported as collisions. -- Absolute or escaping `cwd` values are skipped. -- Cloud-only scripts are skipped. -- Hidden scripts import their command but report the hidden flag as unsupported. -- `scripts.run_mode` and `scripts.auto_run_after_setup` are unsupported. -- `file_include_globs` and `.worktreeinclude` are reported, not converted to shell copy commands. -- Environment variable values are never returned to the client or written into `paseo.json`; only names are shown. -- Spotlight, git, archive, agent, provider, and presentation-only Conductor settings are not migrated. - -Apply re-reads the source files and `paseo.json` before writing. If either changed since preview, the user must refresh the preview before importing. diff --git a/packages/app/e2e/helpers/project-settings.ts b/packages/app/e2e/helpers/project-settings.ts index ad673d6af..eed5bf9b5 100644 --- a/packages/app/e2e/helpers/project-settings.ts +++ b/packages/app/e2e/helpers/project-settings.ts @@ -251,81 +251,6 @@ export async function installReadTransportFailure( }; } -export async function installImportPreviewTransportFailure( - page: Page, -): Promise<{ allowRecovery: () => void }> { - return installSessionRpcTransportFailure( - page, - "project.config.get_import.request", - "Test import preview transport failure.", - ); -} - -export async function installImportApplyTransportFailure( - page: Page, -): Promise<{ allowRecovery: () => void }> { - return installSessionRpcTransportFailure( - page, - "project.config.apply_import.request", - "Test import apply transport failure.", - ); -} - -async function installSessionRpcTransportFailure( - page: Page, - requestType: string, - error: string, -): Promise<{ allowRecovery: () => void }> { - let shouldFail = true; - - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - const server = ws.connectToServer(); - - ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (shouldFail && sessionMessage?.type === requestType) { - const requestId = sessionMessage.requestId; - if (typeof requestId === "string") { - ws.send( - JSON.stringify({ - type: "session", - message: { - type: "rpc_error", - payload: { - requestId, - requestType, - error, - code: "transport", - }, - }, - }), - ); - } - return; - } - try { - server.send(message); - } catch { - // server socket already closed - } - }); - - server.onMessage((message) => { - try { - ws.send(message); - } catch { - // client socket already closed - } - }); - }); - - return { - allowRecovery() { - shouldFail = false; - }, - }; -} - // Installs a transparent WS proxy that can later drop all active daemon connections // and block new ones. Code 1001 (Going Away) without reason triggers "error" state // in DaemonClient due to describeTransportClose returning a non-empty string. diff --git a/packages/app/e2e/projects-settings.spec.ts b/packages/app/e2e/projects-settings.spec.ts index e8a55fe69..2cda10c8d 100644 --- a/packages/app/e2e/projects-settings.spec.ts +++ b/packages/app/e2e/projects-settings.spec.ts @@ -22,8 +22,6 @@ import { expectScriptRowCount, expectWriteFailedCalloutActions, installDaemonConnectionGate, - installImportApplyTransportFailure, - installImportPreviewTransportFailure, installReadTransportFailure, navigateToProjectSettings, openProjectSettings, @@ -335,53 +333,6 @@ test.describe("Projects settings — Conductor project import", () => { } }); - test("malformed TOML and preview transport failures stay actionable in the sheet", async ({ - page, - }) => { - const previewFailure = await installImportPreviewTransportFailure(page); - const workspace = await seedWorkspace({ - repoPrefix: "projects-settings-conductor-invalid-", - repo: { - files: [ - { - path: ".conductor/settings.toml", - content: '[scripts\nsetup = "npm ci"\n', - }, - ], - }, - }); - const serverId = getSeededServerId(workspace.client.getLastServerInfoMessage()); - - try { - await openConductorImportRoute({ - page, - projectId: workspace.projectId, - serverId, - intentId: "preview-transport", - }); - - await expect(page.getByTestId("project-config-import-error")).toContainText( - "Test import preview transport failure.", - { timeout: 30_000 }, - ); - await expect(page.getByTestId("project-config-import-retry")).toBeVisible(); - await expect(page.getByTestId("project-config-import-cancel-error")).toBeVisible(); - - previewFailure.allowRecovery(); - await page.getByTestId("project-config-import-retry").click(); - - await expect(page.getByTestId("project-config-import-error")).toContainText( - ".conductor/settings.toml", - { timeout: 30_000 }, - ); - await expect(page.getByTestId("project-config-import-retry")).toBeVisible(); - await page.getByTestId("project-config-import-cancel-error").click(); - await expect(page.getByTestId("project-config-import-sheet")).not.toBeVisible(); - } finally { - await workspace.cleanup(); - } - }); - test("stale source refresh reloads the preview before import", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "projects-settings-conductor-stale-", @@ -440,57 +391,6 @@ test.describe("Projects settings — Conductor project import", () => { await workspace.cleanup(); } }); - - test("apply transport failure and blocked writes offer retry and cancel", async ({ page }) => { - const applyFailure = await installImportApplyTransportFailure(page); - const workspace = await seedWorkspace({ - repoPrefix: "projects-settings-conductor-apply-fail-", - repo: { - files: [ - { - path: ".conductor/settings.toml", - content: '[scripts]\nsetup = "npm ci"\n', - }, - ], - }, - }); - const serverId = getSeededServerId(workspace.client.getLastServerInfoMessage()); - - try { - await openConductorImportRoute({ - page, - projectId: workspace.projectId, - serverId, - intentId: "apply-transport", - }); - await expectConductorImportPreview(page); - - await page.getByTestId("project-config-import-apply").click(); - await expect(page.getByTestId("project-config-import-error")).toContainText( - "Test import apply transport failure.", - { timeout: 30_000 }, - ); - await expect(page.getByTestId("project-config-import-retry")).toBeVisible(); - - applyFailure.allowRecovery(); - await blockPaseoConfigWrites(workspace.repoPath); - await page.getByTestId("project-config-import-retry").click(); - await expect(page.getByTestId("project-config-import-error")).toContainText( - "Try again, or reload the latest version from disk.", - { timeout: 30_000 }, - ); - await page.getByTestId("project-config-import-retry").click(); - await expect(page.getByTestId("project-config-import-error")).toContainText( - "Try again, or reload the latest version from disk.", - { timeout: 30_000 }, - ); - await page.getByTestId("project-config-import-cancel-error").click(); - await expect(page.getByTestId("project-config-import-sheet")).not.toBeVisible(); - } finally { - await unblockPaseoConfigWrites(workspace.repoPath).catch(() => undefined); - await workspace.cleanup(); - } - }); }); test.describe("Projects settings — error UX", () => { diff --git a/packages/app/src/app/settings/projects/[projectKey].tsx b/packages/app/src/app/settings/projects/[projectKey].tsx index e89ae1f3c..9e5fee471 100644 --- a/packages/app/src/app/settings/projects/[projectKey].tsx +++ b/packages/app/src/app/settings/projects/[projectKey].tsx @@ -1,9 +1,11 @@ -import { useLocalSearchParams } from "expo-router"; -import { useMemo } from "react"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useMemo } from "react"; import SettingsScreen from "@/screens/settings-screen"; -import { parseProjectConfigImportIntent } from "@/project-config-import/project-config-import-model"; +import { parseProjectConfigImportIntent } from "@/project-config-import/route"; +import { projectConfigImportSourceRegistry } from "@/project-config-import/sources"; export default function SettingsProjectDetailRoute() { + const router = useRouter(); const params = useLocalSearchParams<{ projectKey?: string | string[]; importSource?: string | string[]; @@ -12,10 +14,25 @@ export default function SettingsProjectDetailRoute() { }>(); const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey; const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : ""; - const importIntent = useMemo(() => parseProjectConfigImportIntent(params), [params]); + const importIntent = useMemo( + () => parseProjectConfigImportIntent(params, projectConfigImportSourceRegistry), + [params], + ); + const handleImportIntentConsumed = useCallback(() => { + router.setParams({ + importSource: undefined, + importServerId: undefined, + importIntentId: undefined, + }); + }, [router]); const view = useMemo( - () => ({ kind: "project" as const, projectKey, importIntent }), - [importIntent, projectKey], + () => ({ + kind: "project" as const, + projectKey, + importIntent, + onImportIntentConsumed: handleImportIntentConsumed, + }), + [handleImportIntentConsumed, importIntent, projectKey], ); return ; diff --git a/packages/app/src/components/worktree-setup-callout-policy.test.ts b/packages/app/src/components/worktree-setup-callout-policy.test.ts index df3f3d049..d92cfdf41 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.test.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { - buildConductorMigrationCalloutPolicy, + buildProjectConfigImportCalloutPolicy, buildWorktreeSetupCalloutPolicy, selectActiveGitWorkspaceProject, shouldShowWorktreeSetupCallout, @@ -102,26 +102,48 @@ describe("buildWorktreeSetupCalloutPolicy", () => { }); }); - it("builds a host-bound Conductor migration route with a single-use intent", () => { + it("builds a host-bound import route with a single-use intent", () => { expect( - buildConductorMigrationCalloutPolicy( + buildProjectConfigImportCalloutPolicy( { serverId: "server-1", projectKey: "remote:github.com/acme/app", repoRoot: "/repo/project-1", }, - "intent-1", + { + status: "one", + sourceDisplayName: "Fake Source", + sourceRouteValue: "fake", + intentId: "intent-1", + }, ), ).toEqual({ id: "worktree-setup-missing:remote:github.com/acme/app", dismissalKey: "worktree-setup-missing:remote:github.com/acme/app", priority: 100, - title: "Conductor setup found", - description: "Import its workspace setup and run scripts into Paseo.", + title: "Fake Source setup found", + description: "Import workspace setup and run scripts from Fake Source.", actionLabel: "Review migration", projectSettingsRoute: - "/settings/projects/remote%3Agithub.com%2Facme%2Fapp?importSource=conductor&importServerId=server-1&importIntentId=intent-1", + "/settings/projects/remote%3Agithub.com%2Facme%2Fapp?importSource=fake&importServerId=server-1&importIntentId=intent-1", testID: "worktree-setup-callout-remote:github.com/acme/app", }); }); + + it("routes many import sources to project settings without selecting a source", () => { + expect( + buildProjectConfigImportCalloutPolicy( + { + serverId: "server-1", + projectKey: "remote:github.com/acme/app", + repoRoot: "/repo/project-1", + }, + { status: "many" }, + ), + ).toMatchObject({ + title: "Project setup imports found", + description: "Review available project setup imports in Project Settings.", + projectSettingsRoute: "/settings/projects/remote%3Agithub.com%2Facme%2Fapp", + }); + }); }); diff --git a/packages/app/src/components/worktree-setup-callout-policy.ts b/packages/app/src/components/worktree-setup-callout-policy.ts index 9a5e2c37e..4ec2d8126 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.ts @@ -73,25 +73,46 @@ export function buildWorktreeSetupCalloutPolicy( }; } -export function buildConductorMigrationCalloutPolicy( +export function buildProjectConfigImportCalloutPolicy( project: ActiveGitWorkspaceProject, - intentId: string, + input: + | { + status: "one"; + sourceDisplayName: string; + sourceRouteValue: string; + intentId: string; + } + | { status: "many" }, ): WorktreeSetupCalloutPolicy { const calloutKey = `worktree-setup-missing:${project.projectKey}`; + const title = + input.status === "one" + ? i18n.t("sidebar.worktreeSetup.importTitle", { source: input.sourceDisplayName }) + : i18n.t("sidebar.worktreeSetup.importManyTitle"); + const description = + input.status === "one" + ? i18n.t("sidebar.worktreeSetup.importDescription", { + source: input.sourceDisplayName, + }) + : i18n.t("sidebar.worktreeSetup.importManyDescription"); + const projectSettingsRoute = + input.status === "one" + ? buildProjectSettingsImportRoute({ + projectKey: project.projectKey, + source: input.sourceRouteValue, + serverId: project.serverId, + intentId: input.intentId, + }) + : buildProjectSettingsRoute(project.projectKey); return { id: calloutKey, dismissalKey: calloutKey, priority: 100, - title: i18n.t("sidebar.worktreeSetup.conductorTitle"), - description: i18n.t("sidebar.worktreeSetup.conductorDescription"), + title, + description, actionLabel: i18n.t("sidebar.worktreeSetup.reviewMigration"), - projectSettingsRoute: buildProjectSettingsImportRoute({ - projectKey: project.projectKey, - source: "conductor", - serverId: project.serverId, - intentId, - }), + projectSettingsRoute, testID: `worktree-setup-callout-${project.projectKey}`, }; } diff --git a/packages/app/src/components/worktree-setup-callout-source.tsx b/packages/app/src/components/worktree-setup-callout-source.tsx index 55edad648..12d5957ea 100644 --- a/packages/app/src/components/worktree-setup-callout-source.tsx +++ b/packages/app/src/components/worktree-setup-callout-source.tsx @@ -3,13 +3,12 @@ import { useRouter } from "expo-router"; import { useEffect, useMemo } from "react"; import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; import { useStableEvent } from "@/hooks/use-stable-event"; -import { useProjectConfigImportPreview } from "@/project-config-import/project-config-import-preview"; +import { useProjectConfigImportAvailability } from "@/project-config-import/use-project-config-import-model"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { useHostFeature } from "@/runtime/host-features"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { - buildConductorMigrationCalloutPolicy, + buildProjectConfigImportCalloutPolicy, buildWorktreeSetupCalloutPolicy, selectActiveGitWorkspaceProject, shouldShowWorktreeSetupCallout, @@ -23,10 +22,6 @@ export function WorktreeSetupCalloutSource() { (workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace), ); const client = useHostRuntimeClient(activeProject?.serverId ?? ""); - const supportsConductorImport = useHostFeature( - activeProject?.serverId, - "projectConfigImportConductor", - ); const callouts = useSidebarCallouts(); const router = useRouter(); const openProjectSettings = useStableEvent( @@ -48,24 +43,29 @@ export function WorktreeSetupCalloutSource() { }); const shouldConsiderSetup = activeProject && shouldShowWorktreeSetupCallout(readQuery.data); - const importPreviewQuery = useProjectConfigImportPreview({ + const importAvailability = useProjectConfigImportAvailability({ client, - serverId: activeProject?.serverId ?? "", - repoRoot: activeProject?.repoRoot ?? "", - source: { kind: "conductor" }, - enabled: Boolean(shouldConsiderSetup && supportsConductorImport), + serverId: activeProject?.serverId, + repoRoot: activeProject?.repoRoot, + enabled: Boolean(shouldConsiderSetup), }); - const calloutPolicy = useMemo(() => { if (!activeProject || !shouldShowWorktreeSetupCallout(readQuery.data)) { return null; } - const preview = importPreviewQuery.data; - if (supportsConductorImport && preview?.ok === true && preview.status === "available") { - return buildConductorMigrationCalloutPolicy(activeProject, String(Date.now())); + if (importAvailability.status === "one" && importAvailability.source) { + return buildProjectConfigImportCalloutPolicy(activeProject, { + status: "one", + sourceDisplayName: importAvailability.source.module.displayName, + sourceRouteValue: importAvailability.source.module.routeValue, + intentId: String(Date.now()), + }); + } + if (importAvailability.status === "many") { + return buildProjectConfigImportCalloutPolicy(activeProject, { status: "many" }); } return buildWorktreeSetupCalloutPolicy(activeProject); - }, [activeProject, importPreviewQuery.data, readQuery.data, supportsConductorImport]); + }, [activeProject, importAvailability.source, importAvailability.status, readQuery.data]); useEffect(() => { if (!calloutPolicy) { diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index 003323719..fd2eed701 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -24,12 +24,13 @@ type ReplicaQueryInput pushEvent: string; }; -type FetchQueryInput = Omit< +export type FetchQueryInput = Omit< UseQueryOptions, "initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime" > & { dataShape: "list" | "value"; queryFn: QueryFnOption; + refetchOnMount?: UseQueryOptions["refetchOnMount"]; staleTimeMs: number; }; @@ -84,7 +85,7 @@ function replicaQueryOptions< }; } -function fetchQueryOptions< +export function fetchQueryOptions< TQueryFnData, TError = Error, TData = TQueryFnData, @@ -96,7 +97,7 @@ function fetchQueryOptions< throw new Error("Fetch queries must declare a finite staleTimeMs."); } - const { dataShape, meta, staleTimeMs, ...options } = input; + const { dataShape, meta, refetchOnMount, staleTimeMs, ...options } = input; return { ...options, ...(dataShape === "list" ? { placeholderData: keepPreviousData } : {}), @@ -107,7 +108,7 @@ function fetchQueryOptions< dataShape, }, }, - refetchOnMount: "always", + refetchOnMount: refetchOnMount ?? "always", staleTime: staleTimeMs, }; } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index bcb8f0ed3..e17a76e37 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -867,8 +867,10 @@ export const ar: TranslationResources = { description: "أضف أوامر الإعداد حتى تتمكن أشجار العمل الجديدة من تثبيت التبعيات وإعداد نفسها تلقائيًا.", openProjectSettings: "افتح إعدادات المشروع", - conductorTitle: "Conductor setup found", - conductorDescription: "Import its workspace setup and run scripts into Paseo.", + importTitle: "{{source}} setup found", + importDescription: "Import workspace setup and run scripts from {{source}}.", + importManyTitle: "Project setup imports found", + importManyDescription: "Review available project setup imports in Project Settings.", reviewMigration: "Review migration", }, project: { @@ -2068,8 +2070,9 @@ export const ar: TranslationResources = { teardownAccessibility: "أوامر هدم شجرة العمل", }, import: { - rowTitle: "Import from Conductor", - rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + rowTitle: "Import from {{source}}", + rowDescription: + "Review workspace setup and run scripts from {{source}} before writing paseo.json.", sheetTitle: "Import from {{source}}", sources: "Source files", willImport: "Will import", @@ -2078,12 +2081,12 @@ export const ar: TranslationResources = { import: "Import", importing: "Importing...", refreshPreview: "Refresh preview", - success: "Conductor settings imported", + success: "{{source}} settings imported", errorTitle: "Couldn't import settings", errors: { - notFound: "No Conductor project config was found.", + notFound: "No {{source}} project config was found.", invalid: "{{path}} couldn't be parsed.", - staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleSource: "The {{source}} config changed. Refresh the preview before importing.", staleProject: "paseo.json changed. Refresh the preview before importing.", nothing: "There is nothing new to import.", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 2580056ca..421a6dc34 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -878,8 +878,10 @@ export const en = { description: "Add setup commands so new worktrees can install dependencies and prepare themselves automatically.", openProjectSettings: "Open project settings", - conductorTitle: "Conductor setup found", - conductorDescription: "Import its workspace setup and run scripts into Paseo.", + importTitle: "{{source}} setup found", + importDescription: "Import workspace setup and run scripts from {{source}}.", + importManyTitle: "Project setup imports found", + importManyDescription: "Review available project setup imports in Project Settings.", reviewMigration: "Review migration", }, project: { @@ -2082,8 +2084,9 @@ export const en = { teardownAccessibility: "Worktree teardown commands", }, import: { - rowTitle: "Import from Conductor", - rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + rowTitle: "Import from {{source}}", + rowDescription: + "Review workspace setup and run scripts from {{source}} before writing paseo.json.", sheetTitle: "Import from {{source}}", sources: "Source files", willImport: "Will import", @@ -2092,12 +2095,12 @@ export const en = { import: "Import", importing: "Importing...", refreshPreview: "Refresh preview", - success: "Conductor settings imported", + success: "{{source}} settings imported", errorTitle: "Couldn't import settings", errors: { - notFound: "No Conductor project config was found.", + notFound: "No {{source}} project config was found.", invalid: "{{path}} couldn't be parsed.", - staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleSource: "The {{source}} config changed. Refresh the preview before importing.", staleProject: "paseo.json changed. Refresh the preview before importing.", nothing: "There is nothing new to import.", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1654fec56..c32e5d6b3 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -898,8 +898,10 @@ export const es: TranslationResources = { description: "Agregue comandos de configuración para que los nuevos árboles de trabajo puedan instalar dependencias y prepararse automáticamente.", openProjectSettings: "Abrir la configuración del proyecto", - conductorTitle: "Conductor setup found", - conductorDescription: "Import its workspace setup and run scripts into Paseo.", + importTitle: "{{source}} setup found", + importDescription: "Import workspace setup and run scripts from {{source}}.", + importManyTitle: "Project setup imports found", + importManyDescription: "Review available project setup imports in Project Settings.", reviewMigration: "Review migration", }, project: { @@ -2120,8 +2122,9 @@ export const es: TranslationResources = { teardownAccessibility: "Comandos de desmontaje del árbol de trabajo", }, import: { - rowTitle: "Import from Conductor", - rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + rowTitle: "Import from {{source}}", + rowDescription: + "Review workspace setup and run scripts from {{source}} before writing paseo.json.", sheetTitle: "Import from {{source}}", sources: "Source files", willImport: "Will import", @@ -2130,12 +2133,12 @@ export const es: TranslationResources = { import: "Import", importing: "Importing...", refreshPreview: "Refresh preview", - success: "Conductor settings imported", + success: "{{source}} settings imported", errorTitle: "Couldn't import settings", errors: { - notFound: "No Conductor project config was found.", + notFound: "No {{source}} project config was found.", invalid: "{{path}} couldn't be parsed.", - staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleSource: "The {{source}} config changed. Refresh the preview before importing.", staleProject: "paseo.json changed. Refresh the preview before importing.", nothing: "There is nothing new to import.", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 61b15d66a..66ced4d41 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -896,8 +896,10 @@ export const fr: TranslationResources = { description: "Ajoutez des commandes de configuration pour que les nouveaux arbres de travail puissent installer des dépendances et se préparer automatiquement.", openProjectSettings: "Ouvrir les paramètres du projet", - conductorTitle: "Conductor setup found", - conductorDescription: "Import its workspace setup and run scripts into Paseo.", + importTitle: "{{source}} setup found", + importDescription: "Import workspace setup and run scripts from {{source}}.", + importManyTitle: "Project setup imports found", + importManyDescription: "Review available project setup imports in Project Settings.", reviewMigration: "Review migration", }, project: { @@ -2123,8 +2125,9 @@ export const fr: TranslationResources = { teardownAccessibility: "Commandes de démontage de Worktree", }, import: { - rowTitle: "Import from Conductor", - rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + rowTitle: "Import from {{source}}", + rowDescription: + "Review workspace setup and run scripts from {{source}} before writing paseo.json.", sheetTitle: "Import from {{source}}", sources: "Source files", willImport: "Will import", @@ -2133,12 +2136,12 @@ export const fr: TranslationResources = { import: "Import", importing: "Importing...", refreshPreview: "Refresh preview", - success: "Conductor settings imported", + success: "{{source}} settings imported", errorTitle: "Couldn't import settings", errors: { - notFound: "No Conductor project config was found.", + notFound: "No {{source}} project config was found.", invalid: "{{path}} couldn't be parsed.", - staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleSource: "The {{source}} config changed. Refresh the preview before importing.", staleProject: "paseo.json changed. Refresh the preview before importing.", nothing: "There is nothing new to import.", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 696748519..3bdf0585b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -880,8 +880,10 @@ export const ja: TranslationResources = { description: "新しいワークツリーが依存関係をインストールして自動的に準備できるようにセットアップコマンドを追加してください。", openProjectSettings: "プロジェクト設定を開く", - conductorTitle: "Conductor setup found", - conductorDescription: "Import its workspace setup and run scripts into Paseo.", + importTitle: "{{source}} setup found", + importDescription: "Import workspace setup and run scripts from {{source}}.", + importManyTitle: "Project setup imports found", + importManyDescription: "Review available project setup imports in Project Settings.", reviewMigration: "Review migration", }, project: { @@ -2093,8 +2095,9 @@ export const ja: TranslationResources = { teardownAccessibility: "ワークツリー削除時のコマンド", }, import: { - rowTitle: "Import from Conductor", - rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + rowTitle: "Import from {{source}}", + rowDescription: + "Review workspace setup and run scripts from {{source}} before writing paseo.json.", sheetTitle: "Import from {{source}}", sources: "Source files", willImport: "Will import", @@ -2103,12 +2106,12 @@ export const ja: TranslationResources = { import: "Import", importing: "Importing...", refreshPreview: "Refresh preview", - success: "Conductor settings imported", + success: "{{source}} settings imported", errorTitle: "Couldn't import settings", errors: { - notFound: "No Conductor project config was found.", + notFound: "No {{source}} project config was found.", invalid: "{{path}} couldn't be parsed.", - staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleSource: "The {{source}} config changed. Refresh the preview before importing.", staleProject: "paseo.json changed. Refresh the preview before importing.", nothing: "There is nothing new to import.", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 05fb2a2ae..698eedf9c 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -890,8 +890,10 @@ export const ptBR: TranslationResources = { description: "Adicione comandos de configuração para que novos worktrees instalem dependências e se preparem automaticamente.", openProjectSettings: "Abrir configurações do projeto", - conductorTitle: "Conductor setup found", - conductorDescription: "Import its workspace setup and run scripts into Paseo.", + importTitle: "{{source}} setup found", + importDescription: "Import workspace setup and run scripts from {{source}}.", + importManyTitle: "Project setup imports found", + importManyDescription: "Review available project setup imports in Project Settings.", reviewMigration: "Review migration", }, project: { @@ -2106,8 +2108,9 @@ export const ptBR: TranslationResources = { teardownAccessibility: "Comandos de desmontagem do worktree", }, import: { - rowTitle: "Import from Conductor", - rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + rowTitle: "Import from {{source}}", + rowDescription: + "Review workspace setup and run scripts from {{source}} before writing paseo.json.", sheetTitle: "Import from {{source}}", sources: "Source files", willImport: "Will import", @@ -2116,12 +2119,12 @@ export const ptBR: TranslationResources = { import: "Import", importing: "Importing...", refreshPreview: "Refresh preview", - success: "Conductor settings imported", + success: "{{source}} settings imported", errorTitle: "Couldn't import settings", errors: { - notFound: "No Conductor project config was found.", + notFound: "No {{source}} project config was found.", invalid: "{{path}} couldn't be parsed.", - staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleSource: "The {{source}} config changed. Refresh the preview before importing.", staleProject: "paseo.json changed. Refresh the preview before importing.", nothing: "There is nothing new to import.", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 29e39d7b8..7e910b8f2 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -889,8 +889,10 @@ export const ru: TranslationResources = { description: "Добавьте команды настройки, чтобы новые рабочие деревья могли автоматически устанавливать зависимости и готовиться.", openProjectSettings: "Открыть настройки проекта", - conductorTitle: "Conductor setup found", - conductorDescription: "Import its workspace setup and run scripts into Paseo.", + importTitle: "{{source}} setup found", + importDescription: "Import workspace setup and run scripts from {{source}}.", + importManyTitle: "Project setup imports found", + importManyDescription: "Review available project setup imports in Project Settings.", reviewMigration: "Review migration", }, project: { @@ -2111,8 +2113,9 @@ export const ru: TranslationResources = { teardownAccessibility: "Команды разрушения рабочего дерева", }, import: { - rowTitle: "Import from Conductor", - rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + rowTitle: "Import from {{source}}", + rowDescription: + "Review workspace setup and run scripts from {{source}} before writing paseo.json.", sheetTitle: "Import from {{source}}", sources: "Source files", willImport: "Will import", @@ -2121,12 +2124,12 @@ export const ru: TranslationResources = { import: "Import", importing: "Importing...", refreshPreview: "Refresh preview", - success: "Conductor settings imported", + success: "{{source}} settings imported", errorTitle: "Couldn't import settings", errors: { - notFound: "No Conductor project config was found.", + notFound: "No {{source}} project config was found.", invalid: "{{path}} couldn't be parsed.", - staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleSource: "The {{source}} config changed. Refresh the preview before importing.", staleProject: "paseo.json changed. Refresh the preview before importing.", nothing: "There is nothing new to import.", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 1b0811001..0efdf5c32 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -860,8 +860,10 @@ export const zhCN: TranslationResources = { title: "设置 worktree scripts", description: "添加 setup 命令,让新的 worktree 自动安装依赖并完成准备。", openProjectSettings: "打开 project 设置", - conductorTitle: "Conductor setup found", - conductorDescription: "Import its workspace setup and run scripts into Paseo.", + importTitle: "{{source}} setup found", + importDescription: "Import workspace setup and run scripts from {{source}}.", + importManyTitle: "Project setup imports found", + importManyDescription: "Review available project setup imports in Project Settings.", reviewMigration: "Review migration", }, project: { @@ -2044,8 +2046,9 @@ export const zhCN: TranslationResources = { teardownAccessibility: "Worktree teardown 命令", }, import: { - rowTitle: "Import from Conductor", - rowDescription: "Review workspace setup and run scripts before writing paseo.json.", + rowTitle: "Import from {{source}}", + rowDescription: + "Review workspace setup and run scripts from {{source}} before writing paseo.json.", sheetTitle: "Import from {{source}}", sources: "Source files", willImport: "Will import", @@ -2054,12 +2057,12 @@ export const zhCN: TranslationResources = { import: "Import", importing: "Importing...", refreshPreview: "Refresh preview", - success: "Conductor settings imported", + success: "{{source}} settings imported", errorTitle: "Couldn't import settings", errors: { - notFound: "No Conductor project config was found.", + notFound: "No {{source}} project config was found.", invalid: "{{path}} couldn't be parsed.", - staleSource: "The Conductor config changed. Refresh the preview before importing.", + staleSource: "The {{source}} config changed. Refresh the preview before importing.", staleProject: "paseo.json changed. Refresh the preview before importing.", nothing: "There is nothing new to import.", }, diff --git a/packages/app/src/project-config-import/preview-cache.ts b/packages/app/src/project-config-import/preview-cache.ts new file mode 100644 index 000000000..89f731f01 --- /dev/null +++ b/packages/app/src/project-config-import/preview-cache.ts @@ -0,0 +1,80 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages"; +import type { QueryKey } from "@tanstack/react-query"; +import type { FetchQueryInput } from "@/data/query"; +import type { ProjectConfigImportSourceDescriptor } from "./sources"; + +export type ProjectConfigImportPreviewResult = Awaited< + ReturnType +>; + +type ProjectConfigImportPreviewQueryKey = QueryKey; + +const PROJECT_CONFIG_IMPORT_PREVIEW_STALE_MS = 5_000; + +export function projectConfigImportPreviewQueryInput(input: { + client: Pick | null; + serverId: string; + repoRoot: string; + source: ProjectConfigImportSourceDescriptor | null; + protocolSource: ProjectConfigImportSource | null; + enabled: boolean; +}): FetchQueryInput< + ProjectConfigImportPreviewResult, + Error, + ProjectConfigImportPreviewResult, + ProjectConfigImportPreviewQueryKey +> { + return { + queryKey: projectConfigImportPreviewQueryKey(input.serverId, input.repoRoot, input.source), + queryFn: () => { + if (!input.client || !input.protocolSource) { + throw new Error("Project config import preview requires a daemon client and source"); + } + return input.client.getProjectConfigImport({ + repoRoot: input.repoRoot, + source: input.protocolSource, + }); + }, + enabled: + input.enabled && + Boolean(input.client && input.serverId && input.repoRoot && input.protocolSource), + refetchOnMount: false, + retry: false, + staleTimeMs: PROJECT_CONFIG_IMPORT_PREVIEW_STALE_MS, + dataShape: "value", + }; +} + +export function projectConfigImportPreviewQueryKey( + serverId: string, + repoRoot: string, + source: ProjectConfigImportSourceDescriptor | null, +): ProjectConfigImportPreviewQueryKey { + return [ + "project-config-import", + serverId, + repoRoot, + source ? stableProjectConfigImportSourceKey(source) : "none", + ] as const; +} + +export function stableProjectConfigImportSourceKey( + source: ProjectConfigImportSourceDescriptor, +): string { + return stableJson(source); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (value && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/packages/app/src/project-config-import/project-config-import-model.test.ts b/packages/app/src/project-config-import/project-config-import-model.test.ts index 2f3f6e53c..70a86b7d4 100644 --- a/packages/app/src/project-config-import/project-config-import-model.test.ts +++ b/packages/app/src/project-config-import/project-config-import-model.test.ts @@ -1,136 +1,253 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { - normalizeProjectConfigImportError, - openProjectConfigImport, + ProjectConfigImportSourceSchema, + type ProjectConfigImportPreview, + type ProjectConfigImportSource, +} from "@getpaseo/protocol/messages"; +import { fetchQueryOptions } from "@/data/query"; +import { + projectConfigImportPreviewQueryInput, + projectConfigImportPreviewQueryKey, + stableProjectConfigImportSourceKey, +} from "./preview-cache"; +import { + createProjectConfigImportIntentFromRegistration, parseProjectConfigImportIntent, - projectConfigImportApplyFailureRetryAction, - projectConfigImportCanApply, - projectConfigImportNeedsRefresh, - projectConfigImportSourceFeature, -} from "./project-config-import-model"; +} from "./route"; +import { + createProjectConfigImportSourceRegistry, + type ProjectConfigImportSourceDescriptor, +} from "./sources"; + +interface FakeImportSource extends ProjectConfigImportSourceDescriptor { + kind: "fake-source"; + profile: "alpha"; +} +const fakeSource: FakeImportSource = { kind: "fake-source", profile: "alpha" }; +const protocolSource: ProjectConfigImportSource = ProjectConfigImportSourceSchema.options[0].parse({ + kind: ProjectConfigImportSourceSchema.options[0].shape.kind.value, +}); + +const registry = createProjectConfigImportSourceRegistry([ + { + kind: fakeSource.kind, + source: fakeSource, + displayName: "Test Source", + routeValue: "test-source", + }, +]); + +function parseFakeSource(source: ProjectConfigImportSourceDescriptor): FakeImportSource | null { + return source.kind === fakeSource.kind ? fakeSource : null; +} describe("project config import intent", () => { - it("parses a Conductor host-bound route intent", () => { + it("parses a host-bound route intent through the source registry", () => { expect( - parseProjectConfigImportIntent({ - importSource: "conductor", - importServerId: "server-1", - importIntentId: "intent-1", - }), + parseProjectConfigImportIntent( + { + importSource: "test-source", + importServerId: "server-1", + importIntentId: "intent-1", + }, + registry, + parseFakeSource, + ), ).toEqual({ serverId: "server-1", - source: { kind: "conductor" }, + source: fakeSource, + protocolSource: fakeSource, intentId: "intent-1", }); }); it("rejects missing or unknown sources", () => { expect( - parseProjectConfigImportIntent({ - importSource: "other", - importServerId: "server-1", - importIntentId: "intent-1", - }), + parseProjectConfigImportIntent( + { + importSource: "other", + importServerId: "server-1", + importIntentId: "intent-1", + }, + registry, + parseFakeSource, + ), + ).toBeNull(); + expect( + parseProjectConfigImportIntent({ importSource: "test-source" }, registry, parseFakeSource), ).toBeNull(); - expect(parseProjectConfigImportIntent({ importSource: "conductor" })).toBeNull(); }); - it("maps Conductor to its source-specific feature flag", () => { - expect(projectConfigImportSourceFeature({ kind: "conductor" })).toBe( - "projectConfigImportConductor", - ); - }); -}); - -describe("project config import state model", () => { - const intent = { - serverId: "server-1", - source: { kind: "conductor" as const }, - intentId: "intent-1", - }; - const preview = { - requestId: "preview-1", - repoRoot: "/repo/app", - source: { kind: "conductor" as const }, - ok: true as const, - status: "available" as const, - sourceRevision: "source-1", - paseoRevision: null, - inputs: [], - items: [], - preview: { worktree: { setup: "npm ci" } }, - }; - - it("projects loading, ready, applying, and error states", () => { - expect( - openProjectConfigImport({ - intent, - preview: null, - isLoading: true, - error: null, - isApplying: false, - }), - ).toEqual({ status: "loading", intent, preview: null, error: null }); - - const ready = openProjectConfigImport({ - intent, - preview, - isLoading: false, - error: null, - isApplying: false, - }); - expect(ready).toEqual({ status: "ready", intent, preview, error: null }); - expect(projectConfigImportCanApply(ready)).toBe(true); + it("uses the supplied registry instead of a hardcoded route map", () => { + const alternateRegistry = createProjectConfigImportSourceRegistry([ + { + kind: fakeSource.kind, + source: fakeSource, + displayName: "Fake Second", + routeValue: "fake", + }, + ]); expect( - openProjectConfigImport({ - intent, - preview, - isLoading: false, - error: null, - isApplying: true, - }), - ).toEqual({ status: "applying", intent, preview, error: null }); - - expect( - openProjectConfigImport({ - intent, - preview, - isLoading: false, - error: { code: "stale_source_config", source: { kind: "conductor" } }, - isApplying: false, - }), + parseProjectConfigImportIntent( + { + importSource: "fake", + importServerId: "server-1", + importIntentId: "intent-1", + }, + alternateRegistry, + parseFakeSource, + ), ).toEqual({ - status: "error", - intent, - preview, - error: { code: "stale_source_config", source: { kind: "conductor" } }, - retryAction: "refresh", + serverId: "server-1", + source: fakeSource, + protocolSource: fakeSource, + intentId: "intent-1", }); }); - - it("normalizes transport errors and identifies refresh-required domain errors", () => { - expect(normalizeProjectConfigImportError(new Error("socket closed"))).toEqual({ - code: "transport", - message: "socket closed", - }); - expect( - projectConfigImportNeedsRefresh({ - code: "stale_project_config", - currentRevision: null, - }), - ).toBe(true); - expect(projectConfigImportNeedsRefresh({ code: "nothing_to_import" })).toBe(true); - expect(projectConfigImportNeedsRefresh({ code: "write_failed" })).toBe(false); - expect(projectConfigImportApplyFailureRetryAction({ code: "write_failed" })).toBe("apply"); - expect(projectConfigImportApplyFailureRetryAction({ code: "nothing_to_import" })).toBe( - "refresh", - ); - expect( - projectConfigImportApplyFailureRetryAction({ - code: "stale_source_config", - source: { kind: "conductor" }, - }), - ).toBe("refresh"); - }); +}); + +describe("project config import preview cache keys", () => { + it("uses the full source descriptor instead of kind alone", () => { + const alpha = { kind: "variant-source", profile: "alpha" }; + const beta = { profile: "beta", kind: "variant-source" }; + + expect(projectConfigImportPreviewQueryKey("server", "/repo", alpha)).not.toEqual( + projectConfigImportPreviewQueryKey("server", "/repo", beta), + ); + }); + + it("serializes source descriptors deterministically", () => { + expect(stableProjectConfigImportSourceKey({ profile: "alpha", kind: "variant-source" })).toBe( + stableProjectConfigImportSourceKey({ kind: "variant-source", profile: "alpha" }), + ); + }); + + it("does not refetch a current availability preview when the sheet opens", async () => { + const calls: string[] = []; + const rpcSources: ProjectConfigImportSource[] = []; + const client = { + getProjectConfigImport: async (input: { + source: ProjectConfigImportSource; + }): Promise => { + calls.push("preview"); + rpcSources.push(input.source); + return { + ok: true, + requestId: "preview-1", + repoRoot: "/repo", + source: protocolSource, + status: "available", + sourceRevision: "source-1", + paseoRevision: null, + inputs: [], + items: [], + preview: {}, + }; + }, + }; + const queryClient = new QueryClient(); + const input = projectConfigImportPreviewQueryInput({ + client, + serverId: "server", + repoRoot: "/repo", + source: { ...protocolSource, profile: "alpha" }, + protocolSource, + enabled: true, + }); + + const options = fetchQueryOptions(input); + await queryClient.fetchQuery(options); + const observer = new QueryObserver(queryClient, queryClient.defaultQueryOptions(options)); + const unsubscribe = observer.subscribe(() => {}); + observer.getOptimisticResult(queryClient.defaultQueryOptions(options)); + unsubscribe(); + + expect(calls).toEqual(["preview"]); + expect(rpcSources).toEqual([protocolSource]); + expect(input.queryKey).toEqual( + projectConfigImportPreviewQueryKey("server", "/repo", { + ...protocolSource, + profile: "alpha", + }), + ); + }); + + it("keeps advertised identity separate from the protocol source after opening", async () => { + const sameKindRegistry = createProjectConfigImportSourceRegistry([ + { + kind: protocolSource.kind, + source: protocolSource, + displayName: "Protocol Source", + routeValue: "protocol-source", + }, + ]); + const [alphaRegistration, betaRegistration] = sameKindRegistry.advertised([ + { kind: protocolSource.kind, profile: "alpha" }, + { kind: protocolSource.kind, profile: "beta" }, + ]); + const alphaIntent = alphaRegistration + ? createProjectConfigImportIntentFromRegistration({ + serverId: "server", + registration: alphaRegistration, + intentId: "alpha", + }) + : null; + const betaIntent = betaRegistration + ? createProjectConfigImportIntentFromRegistration({ + serverId: "server", + registration: betaRegistration, + intentId: "beta", + }) + : null; + const calls: ProjectConfigImportSource[] = []; + const client = { + getProjectConfigImport: async (input: { + source: ProjectConfigImportSource; + }): Promise => { + calls.push(input.source); + return { + ok: true, + requestId: `preview-${calls.length}`, + repoRoot: "/repo", + source: input.source, + status: "available", + sourceRevision: `source-${calls.length}`, + paseoRevision: null, + inputs: [], + items: [], + preview: {}, + }; + }, + }; + const queryClient = new QueryClient(); + + expect(alphaIntent?.source).toEqual({ kind: protocolSource.kind, profile: "alpha" }); + expect(betaIntent?.source).toEqual({ kind: protocolSource.kind, profile: "beta" }); + expect(alphaIntent?.protocolSource).toEqual(protocolSource); + expect(betaIntent?.protocolSource).toEqual(protocolSource); + + const alphaInput = projectConfigImportPreviewQueryInput({ + client, + serverId: "server", + repoRoot: "/repo", + source: alphaIntent?.source ?? null, + protocolSource: alphaIntent?.protocolSource ?? null, + enabled: true, + }); + const betaInput = projectConfigImportPreviewQueryInput({ + client, + serverId: "server", + repoRoot: "/repo", + source: betaIntent?.source ?? null, + protocolSource: betaIntent?.protocolSource ?? null, + enabled: true, + }); + + expect(alphaInput.queryKey).not.toEqual(betaInput.queryKey); + await queryClient.fetchQuery(fetchQueryOptions(alphaInput)); + await queryClient.fetchQuery(fetchQueryOptions(betaInput)); + expect(calls).toEqual([protocolSource, protocolSource]); + }); }); diff --git a/packages/app/src/project-config-import/project-config-import-model.ts b/packages/app/src/project-config-import/project-config-import-model.ts deleted file mode 100644 index 1049f9d69..000000000 --- a/packages/app/src/project-config-import/project-config-import-model.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { - ProjectConfigImportPreview, - ProjectConfigImportSource, - ProjectConfigRpcError, -} from "@getpaseo/protocol/messages"; - -export interface ProjectConfigImportIntent { - serverId: string; - source: ProjectConfigImportSource; - intentId: string; -} - -export type ProjectConfigImportState = - | { - status: "loading"; - intent: ProjectConfigImportIntent; - preview: ProjectConfigImportPreview | null; - error: null; - } - | { - status: "ready"; - intent: ProjectConfigImportIntent; - preview: ProjectConfigImportPreview; - error: null; - } - | { - status: "applying"; - intent: ProjectConfigImportIntent; - preview: ProjectConfigImportPreview; - error: null; - } - | { - status: "error"; - intent: ProjectConfigImportIntent; - preview: ProjectConfigImportPreview | null; - error: ProjectConfigImportVisibleError; - retryAction: ProjectConfigImportRetryAction; - }; - -export type ProjectConfigImportVisibleError = - | ProjectConfigRpcError - | { code: "transport"; message: string }; -export type ProjectConfigImportRetryAction = "refresh" | "apply"; - -export function openProjectConfigImport(input: { - intent: ProjectConfigImportIntent; - preview: ProjectConfigImportPreview | null; - isLoading: boolean; - error: ProjectConfigImportVisibleError | null; - errorRetryAction?: ProjectConfigImportRetryAction; - isApplying: boolean; -}): ProjectConfigImportState { - if (input.error) { - return { - status: "error", - intent: input.intent, - preview: input.preview, - error: input.error, - retryAction: input.errorRetryAction ?? "refresh", - }; - } - if (input.preview && input.isApplying) { - return { - status: "applying", - intent: input.intent, - preview: input.preview, - error: null, - }; - } - if (input.preview) { - return { - status: "ready", - intent: input.intent, - preview: input.preview, - error: null, - }; - } - return { - status: "loading", - intent: input.intent, - preview: null, - error: null, - }; -} - -export function projectConfigImportCanApply(state: ProjectConfigImportState): boolean { - return ( - state.status === "ready" && - state.preview.status === "available" && - Boolean(state.preview.preview) - ); -} - -export function projectConfigImportNeedsRefresh(error: ProjectConfigImportVisibleError): boolean { - return ( - error.code === "stale_source_config" || - error.code === "stale_project_config" || - error.code === "nothing_to_import" - ); -} - -export function projectConfigImportApplyFailureRetryAction( - error: ProjectConfigImportVisibleError, -): ProjectConfigImportRetryAction { - return projectConfigImportNeedsRefresh(error) ? "refresh" : "apply"; -} - -export function normalizeProjectConfigImportError( - error: ProjectConfigRpcError | Error | null, -): ProjectConfigImportVisibleError | null { - if (!error) { - return null; - } - if (error instanceof Error) { - return { - code: "transport", - message: error.message.length > 0 ? error.message : "The host did not respond.", - }; - } - return error; -} - -export function parseProjectConfigImportIntent(input: { - importSource?: string | string[]; - importServerId?: string | string[]; - importIntentId?: string | string[]; -}): ProjectConfigImportIntent | null { - const source = first(input.importSource); - const serverId = first(input.importServerId); - const intentId = first(input.importIntentId); - if (source !== "conductor" || !serverId || !intentId) { - return null; - } - return { - serverId, - source: { kind: "conductor" }, - intentId, - }; -} - -export function projectConfigImportSourceFeature(source: ProjectConfigImportSource) { - switch (source.kind) { - case "conductor": - return "projectConfigImportConductor" as const; - } -} - -export function sourceLabel(source: ProjectConfigImportSource): string { - switch (source.kind) { - case "conductor": - return "Conductor"; - } -} - -function first(value: string | string[] | undefined): string | null { - const raw = Array.isArray(value) ? value[0] : value; - if (typeof raw !== "string") { - return null; - } - const trimmed = raw.trim(); - return trimmed.length > 0 ? trimmed : null; -} diff --git a/packages/app/src/project-config-import/project-config-import-preview.ts b/packages/app/src/project-config-import/project-config-import-preview.ts deleted file mode 100644 index 1c6c2ca58..000000000 --- a/packages/app/src/project-config-import/project-config-import-preview.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages"; -import { useFetchQuery } from "@/data/query"; - -export function projectConfigImportPreviewQueryKey(input: { - serverId: string; - repoRoot: string; - source: ProjectConfigImportSource; -}) { - return ["project-config-import", input.serverId, input.repoRoot, input.source.kind] as const; -} - -export function useProjectConfigImportPreview(input: { - client: DaemonClient | null; - serverId: string; - repoRoot: string; - source: ProjectConfigImportSource; - enabled: boolean; -}) { - return useFetchQuery({ - queryKey: projectConfigImportPreviewQueryKey(input), - queryFn: () => { - if (!input.client) { - throw new Error("Project config import preview requires a daemon client"); - } - return input.client.getProjectConfigImport({ - repoRoot: input.repoRoot, - source: input.source, - }); - }, - dataShape: "value", - enabled: input.enabled && Boolean(input.client && input.serverId && input.repoRoot), - retry: false, - staleTimeMs: 5_000, - }); -} diff --git a/packages/app/src/project-config-import/project-config-import-section.tsx b/packages/app/src/project-config-import/project-config-import-section.tsx new file mode 100644 index 000000000..6998ce36c --- /dev/null +++ b/packages/app/src/project-config-import/project-config-import-section.tsx @@ -0,0 +1,114 @@ +import { Pressable, Text, View } from "react-native"; +import { useCallback } from "react"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { settingsStyles } from "@/styles/settings"; +import { stableProjectConfigImportSourceKey } from "./preview-cache"; +import type { ProjectConfigImportIntent } from "./route"; +import { ProjectConfigImportSheet } from "./project-config-import-sheet"; +import type { ProjectConfigImportSourceRegistration } from "./sources"; +import { getProjectConfigImportViewModule } from "./sources/view"; +import { useProjectConfigImportModel } from "./use-project-config-import-model"; + +export function ProjectConfigImportSection(input: { + client: DaemonClient; + serverId: string; + repoRoot: string; + routeIntent: ProjectConfigImportIntent | null; + onRouteIntentConsumed?: () => void; + projectConfigLoaded: boolean; + projectConfigQueryKey: readonly [string, string, string]; +}) { + const model = useProjectConfigImportModel(input); + + return ( + <> + {model.sources.map((source) => ( + + ))} + {model.state ? ( + + ) : null} + + ); +} + +function ProjectConfigImportRow({ + source, + activeSourceKind, + isAvailable, + onOpen, +}: { + source: ProjectConfigImportSourceRegistration; + activeSourceKind: string | null; + isAvailable: boolean; + onOpen: (source: ProjectConfigImportSourceRegistration) => void; +}) { + const { t } = useTranslation(); + const shouldShow = isAvailable || activeSourceKind === source.kind; + const handleOpen = useCallback(() => { + onOpen(source); + }, [onOpen, source]); + + if (!shouldShow) { + return null; + } + + const SourceIcon = getProjectConfigImportViewModule(source.source).Icon; + + const rowTitle = t("settings.project.import.rowTitle", { source: source.module.displayName }); + return ( + + + + + + {rowTitle} + + {t("settings.project.import.rowDescription", { source: source.module.displayName })} + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + importRow: { + ...settingsStyles.row, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + importText: { + flex: 1, + minWidth: 0, + }, + iconColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/project-config-import/project-config-import-sheet.tsx b/packages/app/src/project-config-import/project-config-import-sheet.tsx index cecfcdd00..e50f0a0f5 100644 --- a/packages/app/src/project-config-import/project-config-import-sheet.tsx +++ b/packages/app/src/project-config-import/project-config-import-sheet.tsx @@ -1,26 +1,43 @@ -import { useMemo } from "react"; import { Text, View } from "react-native"; +import { useMemo } from "react"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; -import type { ProjectConfigImportPreview } from "@getpaseo/protocol/messages"; -import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import type { + ProjectConfigImportItem, + ProjectConfigImportPreview, + ProjectConfigRpcError, +} from "@getpaseo/protocol/messages"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { settingsStyles } from "@/styles/settings"; -import { ConductorIcon } from "./conductor-icon"; -import { - projectConfigImportCanApply, - projectConfigImportNeedsRefresh, - sourceLabel, - type ProjectConfigImportState, - type ProjectConfigImportVisibleError, -} from "./project-config-import-model"; +import type { ProjectConfigImportIntent } from "./route"; + +export type ProjectConfigImportVisibleError = + | ProjectConfigRpcError + | { code: "transport"; message: string }; + +export type ProjectConfigImportState = + | { status: "loading"; intent: ProjectConfigImportIntent; preview: null; error: null } + | { + status: "ready" | "applying"; + intent: ProjectConfigImportIntent; + preview: ProjectConfigImportPreview; + error: null; + } + | { + status: "error"; + intent: ProjectConfigImportIntent; + preview: ProjectConfigImportPreview | null; + error: ProjectConfigImportVisibleError; + retryAction: "refresh" | "apply"; + }; interface ProjectConfigImportSheetProps { visible: boolean; state: ProjectConfigImportState; + sourceName: string; onClose: () => void; onRefresh: () => void; onApply: () => void; @@ -29,23 +46,28 @@ interface ProjectConfigImportSheetProps { export function ProjectConfigImportSheet({ visible, state, + sourceName, onClose, onRefresh, onApply, }: ProjectConfigImportSheetProps) { const { t } = useTranslation(); - const header = useMemo( - () => ({ - title: t("settings.project.import.sheetTitle", { source: sourceLabel(state.intent.source) }), - }), - [state.intent.source, t], + const header = useMemo( + () => ({ title: t("settings.project.import.sheetTitle", { source: sourceName }) }), + [sourceName, t], ); const preview = state.preview; const visibleError = state.status === "error" ? state.error : null; - const needsRefresh = visibleError ? projectConfigImportNeedsRefresh(visibleError) : false; + const needsRefresh = + visibleError?.code === "stale_source_config" || + visibleError?.code === "stale_project_config" || + visibleError?.code === "nothing_to_import"; const retryAction = state.status === "error" ? state.retryAction : "refresh"; const handleRetry = retryAction === "apply" ? onApply : onRefresh; - const canImport = projectConfigImportCanApply(state); + const canImport = + state.status === "ready" && + state.preview.status === "available" && + Boolean(state.preview.preview); const isLoading = state.status === "loading"; const isApplying = state.status === "applying"; @@ -69,7 +91,7 @@ export function ProjectConfigImportSheet({ testID="project-config-import-error" variant="error" title={t("settings.project.import.errorTitle")} - description={projectConfigImportErrorText(visibleError, t)} + description={projectConfigImportErrorText(visibleError, t, sourceName)} > {needsRefresh ? (