refactor(settings): make project imports pluggable

This commit is contained in:
Mohamed Boudra
2026-07-17 14:42:02 +02:00
parent f7a6548b68
commit f030ddefb1
53 changed files with 2128 additions and 1546 deletions
@@ -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 <SettingsScreen view={view} />;
@@ -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",
});
});
});
@@ -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}`,
};
}
@@ -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) {
+5 -4
View File
@@ -24,12 +24,13 @@ type ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey>
pushEvent: string;
};
type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
export type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
"initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime"
> & {
dataShape: "list" | "value";
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
refetchOnMount?: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>["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,
};
}
+10 -7
View File
@@ -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.",
},
+10 -7
View File
@@ -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.",
},
+10 -7
View File
@@ -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.",
},
+10 -7
View File
@@ -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.",
},
+10 -7
View File
@@ -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.",
},
+10 -7
View File
@@ -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.",
},
+10 -7
View File
@@ -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.",
},
+10 -7
View File
@@ -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.",
},
@@ -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<DaemonClient["getProjectConfigImport"]>
>;
type ProjectConfigImportPreviewQueryKey = QueryKey;
const PROJECT_CONFIG_IMPORT_PREVIEW_STALE_MS = 5_000;
export function projectConfigImportPreviewQueryInput(input: {
client: Pick<DaemonClient, "getProjectConfigImport"> | 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<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
@@ -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",
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<ProjectConfigImportPreview & { ok: true; requestId: string }> => {
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,
});
expect(
projectConfigImportNeedsRefresh({
code: "stale_project_config",
currentRevision: null,
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",
}),
).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");
});
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<ProjectConfigImportPreview & { ok: true; requestId: string }> => {
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]);
});
});
@@ -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;
}
@@ -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,
});
}
@@ -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) => (
<ProjectConfigImportRow
key={stableProjectConfigImportSourceKey(source.source)}
source={source}
activeSourceKind={model.intent?.source.kind ?? null}
isAvailable={model.availability.availableSourceKeys.has(
stableProjectConfigImportSourceKey(source.source),
)}
onOpen={model.open}
/>
))}
{model.state ? (
<ProjectConfigImportSheet
visible
state={model.state}
sourceName={model.activeSourceName ?? model.state.intent.source.kind}
onClose={model.close}
onRefresh={model.refresh}
onApply={model.apply}
/>
) : 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 (
<SettingsSection title={rowTitle} testID="project-config-import-section">
<Pressable
testID="project-config-import-row"
accessibilityRole="button"
accessibilityLabel={rowTitle}
onPress={handleOpen}
style={settingsStyles.card}
>
<View style={styles.importRow}>
<SourceIcon size={16} color={styles.iconColor.color} />
<View style={styles.importText}>
<Text style={settingsStyles.rowTitle}>{rowTitle}</Text>
<Text style={settingsStyles.rowHint} numberOfLines={2}>
{t("settings.project.import.rowDescription", { source: source.module.displayName })}
</Text>
</View>
</View>
</Pressable>
</SettingsSection>
);
}
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,
},
}));
@@ -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<SheetHeader>(
() => ({
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 ? (
<Button
@@ -127,38 +149,41 @@ export function ProjectConfigImportSheet({
function PreviewBody({ preview }: { preview: ProjectConfigImportPreview }) {
const { t } = useTranslation();
const grouped = groupPreviewItems(preview);
const sections = [
{
title: t("settings.project.import.sources"),
items: preview.inputs.map((input) => ({
key: `${input.role}:${input.relativePath}`,
label: input.relativePath,
outcome: "import" as const,
detail: input.role,
})),
},
{
title: t("settings.project.import.willImport"),
items: preview.items.filter((item) => item.outcome === "import"),
},
{
title: t("settings.project.import.needsAttention"),
items: preview.items.filter(
(item) => item.outcome === "rewrite" || item.outcome === "collision",
),
},
{
title: t("settings.project.import.notSupported"),
items: preview.items.filter((item) => item.outcome === "unsupported"),
},
];
return (
<View style={styles.preview}>
<View style={styles.sourceRow}>
<ConductorIcon size={18} color={styles.iconColor.color} />
<Text style={settingsStyles.rowTitle}>{t("settings.project.import.sources")}</Text>
</View>
<View style={settingsStyles.card}>
{preview.inputs.map((input, index) => (
<View
key={`${input.role}:${input.relativePath}`}
style={index === 0 ? settingsStyles.row : styles.rowWithBorder}
>
<Text style={settingsStyles.rowTitle}>{input.relativePath}</Text>
<Text style={settingsStyles.rowHint}>{input.role}</Text>
</View>
))}
</View>
<ItemGroup title={t("settings.project.import.willImport")} items={grouped.imports} />
<ItemGroup title={t("settings.project.import.needsAttention")} items={grouped.attention} />
<ItemGroup title={t("settings.project.import.notSupported")} items={grouped.unsupported} />
{sections.map((section) => (
<PreviewItemGroup key={section.title} title={section.title} items={section.items} />
))}
</View>
);
}
function ItemGroup({
title,
items,
}: {
title: string;
items: ProjectConfigImportPreview["items"];
}) {
function PreviewItemGroup({ title, items }: { title: string; items: ProjectConfigImportItem[] }) {
if (items.length === 0) {
return null;
}
@@ -167,10 +192,7 @@ function ItemGroup({
<Text style={styles.groupTitle}>{title}</Text>
<View style={settingsStyles.card}>
{items.map((item, index) => (
<View
key={`${item.key}:${item.outcome}:${item.detail ?? ""}`}
style={index === 0 ? settingsStyles.row : styles.rowWithBorder}
>
<View key={`${item.key}:${item.outcome}:${item.detail ?? ""}`} style={rowStyle(index)}>
<Text style={settingsStyles.rowTitle}>{item.label}</Text>
{item.detail ? <Text style={styles.commandText}>{item.detail}</Text> : null}
</View>
@@ -180,29 +202,24 @@ function ItemGroup({
);
}
function groupPreviewItems(preview: ProjectConfigImportPreview) {
return {
imports: preview.items.filter((item) => item.outcome === "import"),
attention: preview.items.filter(
(item) => item.outcome === "rewrite" || item.outcome === "collision",
),
unsupported: preview.items.filter((item) => item.outcome === "unsupported"),
};
function rowStyle(index: number) {
return index === 0 ? settingsStyles.row : styles.rowWithBorder;
}
function projectConfigImportErrorText(
error: ProjectConfigImportVisibleError,
t: TFunction,
t: ReturnType<typeof useTranslation>["t"],
sourceName: string,
): string {
switch (error.code) {
case "transport":
return error.message;
case "source_config_not_found":
return t("settings.project.import.errors.notFound");
return t("settings.project.import.errors.notFound", { source: sourceName });
case "invalid_source_config":
return t("settings.project.import.errors.invalid", { path: error.relativePath });
case "stale_source_config":
return t("settings.project.import.errors.staleSource");
return t("settings.project.import.errors.staleSource", { source: sourceName });
case "stale_project_config":
return t("settings.project.import.errors.staleProject");
case "nothing_to_import":
@@ -228,11 +245,6 @@ const styles = StyleSheet.create((theme) => ({
preview: {
gap: theme.spacing[4],
},
sourceRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
group: {
gap: theme.spacing[2],
},
@@ -259,7 +271,4 @@ const styles = StyleSheet.create((theme) => ({
spinnerColor: {
color: theme.colors.foregroundMuted,
},
iconColor: {
color: theme.colors.foreground,
},
}));
@@ -0,0 +1,89 @@
import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages";
import { ProjectConfigImportSourceSchema } from "@getpaseo/protocol/messages";
import type {
ProjectConfigImportSourceDescriptor,
ProjectConfigImportSourceRegistry,
} from "./sources";
export interface ProjectConfigImportIntent<
TSource extends ProjectConfigImportSourceDescriptor = ProjectConfigImportSourceDescriptor,
TProtocolSource extends ProjectConfigImportSourceDescriptor = ProjectConfigImportSource,
> {
serverId: string;
source: TSource;
protocolSource: TProtocolSource;
intentId: string;
}
export function parseProjectConfigImportIntent(
input: {
importSource?: string | string[];
importServerId?: string | string[];
importIntentId?: string | string[];
},
registry: ProjectConfigImportSourceRegistry,
): ProjectConfigImportIntent | null;
export function parseProjectConfigImportIntent<TSource extends ProjectConfigImportSourceDescriptor>(
input: {
importSource?: string | string[];
importServerId?: string | string[];
importIntentId?: string | string[];
},
registry: ProjectConfigImportSourceRegistry,
parseSource: (source: ProjectConfigImportSourceDescriptor) => TSource | null,
): ProjectConfigImportIntent<ProjectConfigImportSourceDescriptor, TSource> | null;
export function parseProjectConfigImportIntent(
input: {
importSource?: string | string[];
importServerId?: string | string[];
importIntentId?: string | string[];
},
registry: ProjectConfigImportSourceRegistry,
parseSource?: (
source: ProjectConfigImportSourceDescriptor,
) => ProjectConfigImportSourceDescriptor | null,
): ProjectConfigImportIntent<
ProjectConfigImportSourceDescriptor,
ProjectConfigImportSourceDescriptor
> | null {
const source = first(input.importSource);
const serverId = first(input.importServerId);
const intentId = first(input.importIntentId);
const routeSource = source ? registry.fromRouteValue(source)?.source : null;
const parsedSource = routeSource ? (parseSource ?? parseProtocolSource)(routeSource) : null;
return parsedSource && routeSource && serverId && intentId
? { serverId, source: routeSource, protocolSource: parsedSource, intentId }
: null;
}
export function createProjectConfigImportIntentFromRegistration(input: {
serverId: string;
registration: {
source: ProjectConfigImportSourceDescriptor;
protocolSource: ProjectConfigImportSource | null;
};
intentId: string;
}): ProjectConfigImportIntent | null {
return input.registration.protocolSource
? {
serverId: input.serverId,
source: input.registration.source,
protocolSource: input.registration.protocolSource,
intentId: input.intentId,
}
: null;
}
function parseProtocolSource(
source: ProjectConfigImportSourceDescriptor,
): ProjectConfigImportSource | null {
const parsed = ProjectConfigImportSourceSchema.safeParse(source);
return parsed.success ? parsed.data : null;
}
function first(value: string | string[] | undefined): string | null {
const raw = Array.isArray(value) ? value[0] : value;
return typeof raw === "string" && raw.trim() ? raw.trim() : null;
}
@@ -0,0 +1,8 @@
import type { ProjectConfigImportLogicModule } from ".";
export const conductorProjectConfigImportSource = {
kind: "conductor",
source: { kind: "conductor" },
displayName: "Conductor",
routeValue: "conductor",
} satisfies ProjectConfigImportLogicModule<{ kind: "conductor" }>;
@@ -0,0 +1,12 @@
import { createElement } from "react";
import type { ProjectConfigImportIconProps, ProjectConfigImportViewModule } from "./view-registry";
function ConductorImportIcon(props: ProjectConfigImportIconProps) {
const { ConductorIcon } = require("./conductor.view") as typeof import("./conductor.view");
return createElement(ConductorIcon, props);
}
export const conductorProjectConfigImportView = {
kind: "conductor",
Icon: ConductorImportIcon,
} satisfies ProjectConfigImportViewModule;
@@ -1,4 +1,5 @@
import Svg, { Path } from "react-native-svg";
import type { ProjectConfigImportViewModule } from "./view-registry";
const PATHS = [
"M4.57422 63.6992H22.373V37.251H4.57422C3.58785 37.2511 2.78711 38.0517 2.78711 39.0381V61.9121C2.78725 62.8984 3.58794 63.6991 4.57422 63.6992Z",
@@ -31,3 +32,8 @@ export function ConductorIcon({ size = 18, color = "#282423" }: { size?: number;
</Svg>
);
}
export const conductorProjectConfigImportView = {
kind: "conductor",
Icon: ConductorIcon,
} satisfies ProjectConfigImportViewModule;
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
createProjectConfigImportSourceRegistry,
projectConfigImportSourceRegistry,
type ProjectConfigImportLogicModule,
} from ".";
import { projectConfigImportViewRegistry } from "./view";
import { createProjectConfigImportViewRegistry } from "./view-registry";
const fakeSource = {
kind: "fake-source",
source: { kind: "fake-source", profile: "alpha" },
displayName: "Fake Source",
routeValue: "fake",
} satisfies ProjectConfigImportLogicModule<{ kind: "fake-source"; profile: "alpha" }>;
describe("project config import app registry", () => {
it("filters future advertised sources through registered logic modules", () => {
const registry = createProjectConfigImportSourceRegistry([fakeSource]);
expect(
registry.advertised([
{ kind: "future-source", capability: "unknown" },
{ kind: "fake-source", profile: "alpha" },
]),
).toEqual([
{
kind: "fake-source",
source: { kind: "fake-source", profile: "alpha" },
protocolSource: null,
module: fakeSource,
},
]);
});
it("preserves advertised descriptors before normalizing RPC sources", () => {
const registry = createProjectConfigImportSourceRegistry([
{
kind: "conductor",
source: { kind: "conductor" },
displayName: "Known Source",
routeValue: "known",
},
]);
expect(registry.advertised([{ kind: "conductor", profile: "alpha" }])).toMatchObject([
{
kind: "conductor",
source: { kind: "conductor", profile: "alpha" },
protocolSource: { kind: "conductor" },
module: { displayName: "Known Source" },
},
]);
});
it("rejects duplicate logic kinds and route values", () => {
expect(() => createProjectConfigImportSourceRegistry([fakeSource, fakeSource])).toThrow(
"Duplicate project config import source: fake-source",
);
expect(() =>
createProjectConfigImportSourceRegistry([
fakeSource,
{ ...fakeSource, kind: "other-source" },
]),
).toThrow("Duplicate project config import route value: fake");
});
it("keeps production protocol sources registered", () => {
expect(() => projectConfigImportSourceRegistry.assertProtocolCoverage()).not.toThrow();
});
});
describe("project config import view registry", () => {
const FakeIcon = () => null;
it("rejects duplicate views and missing view modules", () => {
expect(() =>
createProjectConfigImportViewRegistry([
{ kind: "fake-source", Icon: FakeIcon },
{ kind: "fake-source", Icon: FakeIcon },
]),
).toThrow("Duplicate project config import view: fake-source");
const registry = createProjectConfigImportViewRegistry([]);
expect(() => registry.get(fakeSource.source)).toThrow(
"Missing project config import view: fake-source",
);
});
it("keeps production logic and view registries in parity", () => {
const kinds = projectConfigImportSourceRegistry.all().map((source) => source.kind);
expect(projectConfigImportViewRegistry.kinds()).toEqual(kinds);
});
});
@@ -0,0 +1,106 @@
import { ProjectConfigImportSourceSchema } from "@getpaseo/protocol/messages";
import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages";
import { conductorProjectConfigImportSource } from "./conductor";
export interface ProjectConfigImportSourceDescriptor {
kind: string;
[key: string]: unknown;
}
export interface ProjectConfigImportLogicModule<
TSource extends ProjectConfigImportSourceDescriptor = ProjectConfigImportSourceDescriptor,
> {
readonly kind: TSource["kind"];
readonly source: TSource;
readonly displayName: string;
readonly routeValue: string;
}
export interface ProjectConfigImportSourceRegistration<
TSource extends ProjectConfigImportSourceDescriptor = ProjectConfigImportSourceDescriptor,
> {
readonly kind: string;
readonly source: TSource;
readonly protocolSource: ProjectConfigImportSource | null;
readonly module: ProjectConfigImportLogicModule;
}
const PROJECT_CONFIG_IMPORT_LOGIC_MODULES = [conductorProjectConfigImportSource];
export const projectConfigImportSourceRegistry = createProjectConfigImportSourceRegistry(
PROJECT_CONFIG_IMPORT_LOGIC_MODULES,
);
export type ProjectConfigImportSourceRegistry = ReturnType<
typeof createProjectConfigImportSourceRegistry
>;
export function createProjectConfigImportSourceRegistry<
const TModules extends readonly ProjectConfigImportLogicModule[],
>(modules: TModules) {
const byKind = new Map<string, ProjectConfigImportLogicModule>();
const byRouteValue = new Map<string, ProjectConfigImportLogicModule>();
for (const module of modules) {
if (byKind.has(module.kind)) {
throw new Error(`Duplicate project config import source: ${module.kind}`);
}
if (byRouteValue.has(module.routeValue)) {
throw new Error(`Duplicate project config import route value: ${module.routeValue}`);
}
byKind.set(module.kind, module);
byRouteValue.set(module.routeValue, module);
}
return {
all(): ProjectConfigImportLogicModule[] {
return Array.from(byKind.values());
},
get(source: ProjectConfigImportSourceDescriptor): ProjectConfigImportLogicModule | null {
return byKind.get(source.kind) ?? null;
},
fromRouteValue(routeValue: string): ProjectConfigImportLogicModule | null {
return byRouteValue.get(routeValue) ?? null;
},
routeValue(source: ProjectConfigImportSourceDescriptor): string | null {
return byKind.get(source.kind)?.routeValue ?? null;
},
assertProtocolCoverage(): void {
const missing = readProtocolSourceKinds().filter((kind) => !byKind.has(kind));
if (missing.length > 0) {
throw new Error(`Missing project config import sources: ${missing.join(", ")}`);
}
},
advertised(
sources: readonly ProjectConfigImportSourceDescriptor[] | null | undefined,
): ProjectConfigImportSourceRegistration[] {
if (!sources) {
return [];
}
return sources
.map((source) => {
const module = byKind.get(source.kind);
return module
? {
kind: source.kind,
source,
protocolSource: toProjectConfigImportProtocolSource(source),
module,
}
: null;
})
.filter((source): source is ProjectConfigImportSourceRegistration => source !== null);
},
};
}
export function toProjectConfigImportProtocolSource(
source: ProjectConfigImportSourceDescriptor,
): ProjectConfigImportSource | null {
const parsed = ProjectConfigImportSourceSchema.safeParse(source);
return parsed.success ? parsed.data : null;
}
function readProtocolSourceKinds(): string[] {
return ProjectConfigImportSourceSchema.options.map((option) => option.shape.kind.value);
}
@@ -0,0 +1,37 @@
import type { ComponentType } from "react";
import type { ProjectConfigImportSourceDescriptor } from ".";
export interface ProjectConfigImportIconProps {
size?: number;
color?: string;
}
export interface ProjectConfigImportViewModule {
readonly kind: string;
readonly Icon: ComponentType<ProjectConfigImportIconProps>;
}
export function createProjectConfigImportViewRegistry(
modules: readonly ProjectConfigImportViewModule[],
) {
const byKind = new Map<string, ProjectConfigImportViewModule>();
for (const module of modules) {
if (byKind.has(module.kind)) {
throw new Error(`Duplicate project config import view: ${module.kind}`);
}
byKind.set(module.kind, module);
}
return {
kinds(): string[] {
return Array.from(byKind.keys());
},
get(source: ProjectConfigImportSourceDescriptor): ProjectConfigImportViewModule {
const module = byKind.get(source.kind);
if (!module) {
throw new Error(`Missing project config import view: ${source.kind}`);
}
return module;
},
};
}
@@ -0,0 +1,17 @@
import type { ProjectConfigImportSourceDescriptor } from ".";
import { conductorProjectConfigImportView } from "./conductor.view-registration";
import { createProjectConfigImportViewRegistry } from "./view-registry";
import type { ProjectConfigImportViewModule } from "./view-registry";
export type { ProjectConfigImportIconProps, ProjectConfigImportViewModule } from "./view-registry";
const PROJECT_CONFIG_IMPORT_VIEW_MODULES = [conductorProjectConfigImportView];
export const projectConfigImportViewRegistry = createProjectConfigImportViewRegistry(
PROJECT_CONFIG_IMPORT_VIEW_MODULES,
);
export function getProjectConfigImportViewModule(
source: ProjectConfigImportSourceDescriptor,
): ProjectConfigImportViewModule {
return projectConfigImportViewRegistry.get(source);
}
@@ -1,67 +1,124 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProjectConfigImportPreview } from "@getpaseo/protocol/messages";
import { useToast } from "@/contexts/toast-context";
import {
normalizeProjectConfigImportError,
openProjectConfigImport,
projectConfigImportApplyFailureRetryAction,
type ProjectConfigImportAdvertisedSource,
type ProjectConfigImportSource,
type ProjectConfigRpcError,
} from "@getpaseo/protocol/messages";
import { useToast } from "@/contexts/toast-context";
import { useFetchQueries, useFetchQuery } from "@/data/query";
import { useSessionStore } from "@/stores/session-store";
import {
projectConfigImportPreviewQueryInput,
stableProjectConfigImportSourceKey,
type ProjectConfigImportPreviewResult,
} from "./preview-cache";
import type {
ProjectConfigImportState,
ProjectConfigImportVisibleError,
} from "./project-config-import-sheet";
import {
createProjectConfigImportIntentFromRegistration,
type ProjectConfigImportIntent,
type ProjectConfigImportRetryAction,
type ProjectConfigImportState,
type ProjectConfigImportVisibleError,
} from "./project-config-import-model";
} from "./route";
import {
type ProjectConfigImportSourceRegistration,
projectConfigImportSourceRegistry,
type ProjectConfigImportSourceRegistry,
} from "./sources";
export interface ProjectConfigImportModel {
state: ProjectConfigImportState;
apply: () => void;
}
const EMPTY_IMPORT_SOURCES: readonly ProjectConfigImportAdvertisedSource[] = [];
export function useProjectConfigImportModel(input: {
intent: ProjectConfigImportIntent;
routeIntent: ProjectConfigImportIntent | null;
repoRoot: string;
client: DaemonClient;
preview: ProjectConfigImportPreview | null;
isPreviewLoading: boolean;
previewError: ProjectConfigImportVisibleError | null;
serverId: string;
client: DaemonClient | null;
projectConfigLoaded: boolean;
projectConfigQueryKey: readonly [string, string, string];
onClose: () => void;
}): ProjectConfigImportModel {
const { t } = useTranslation();
registry?: ProjectConfigImportSourceRegistry;
onRouteIntentConsumed?: () => void;
}) {
const registry = input.registry ?? projectConfigImportSourceRegistry;
const onRouteIntentConsumed = input.onRouteIntentConsumed;
const sources = useAdvertisedProjectConfigImportSources(input.serverId, registry);
const [intent, setIntent] = useState<ProjectConfigImportIntent | null>(null);
const consumedRouteIntentKeyRef = useRef<string | null>(null);
const acknowledgedRouteIntentKeyRef = useRef<string | null>(null);
const [applyError, setApplyError] = useState<ProjectConfigImportVisibleError | null>(null);
const [retryAction, setRetryAction] = useState<ProjectConfigImportRetryAction>("apply");
const activeSource = intent ? registry.get(intent.source) : null;
const activePreview = useProjectConfigImportPreviewQuery({
client: input.client,
serverId: input.serverId,
repoRoot: input.repoRoot,
source: intent?.source ?? null,
protocolSource: intent?.protocolSource ?? null,
enabled: Boolean(intent && input.projectConfigLoaded),
});
const preview = activePreview.data?.ok ? activePreview.data : null;
const queryClient = useQueryClient();
const toast = useToast();
const [applyError, setApplyError] = useState<ProjectConfigImportVisibleError | null>(null);
const [applyErrorRetryAction, setApplyErrorRetryAction] =
useState<ProjectConfigImportRetryAction>("apply");
const previewRevisionKey = [
input.intent.intentId,
input.preview?.sourceRevision ?? "",
input.preview?.paseoRevision?.mtimeMs ?? "",
input.preview?.paseoRevision?.size ?? "",
].join(":");
const { t } = useTranslation();
useEffect(() => {
const routeIntentKey = input.routeIntent
? `${input.routeIntent.serverId}:${stableProjectConfigImportSourceKey(input.routeIntent.source)}:${input.routeIntent.intentId}`
: null;
if (
input.routeIntent?.serverId === input.serverId &&
routeIntentKey &&
consumedRouteIntentKeyRef.current !== routeIntentKey
) {
consumedRouteIntentKeyRef.current = routeIntentKey;
setIntent(input.routeIntent);
}
}, [input.routeIntent, input.serverId]);
useEffect(() => {
const routeIntentKey = input.routeIntent
? `${input.routeIntent.serverId}:${stableProjectConfigImportSourceKey(input.routeIntent.source)}:${input.routeIntent.intentId}`
: null;
const openIntentKey = intent
? `${intent.serverId}:${stableProjectConfigImportSourceKey(intent.source)}:${intent.intentId}`
: null;
if (
routeIntentKey &&
routeIntentKey === openIntentKey &&
acknowledgedRouteIntentKeyRef.current !== routeIntentKey
) {
acknowledgedRouteIntentKeyRef.current = routeIntentKey;
onRouteIntentConsumed?.();
}
}, [input.routeIntent, intent, onRouteIntentConsumed]);
useEffect(() => {
setApplyError(null);
}, [previewRevisionKey]);
}, [
intent?.intentId,
preview?.sourceRevision,
preview?.paseoRevision?.mtimeMs,
preview?.paseoRevision?.size,
]);
const applyMutation = useMutation({
mutationFn: async () => {
if (!input.preview?.sourceRevision) {
if (!input.client || !intent || !preview?.sourceRevision) {
throw new Error("Import preview is not available");
}
return input.client.applyProjectConfigImport({
repoRoot: input.repoRoot,
source: input.intent.source,
expectedSourceRevision: input.preview.sourceRevision,
expectedPaseoRevision: input.preview.paseoRevision,
source: intent.protocolSource,
expectedSourceRevision: preview.sourceRevision,
expectedPaseoRevision: preview.paseoRevision,
});
},
onSuccess: (result) => {
if (!result.ok) {
setApplyError(result.error);
setApplyErrorRetryAction(projectConfigImportApplyFailureRetryAction(result.error));
setRetryAction(projectConfigImportApplyFailureRetryAction(result.error));
return;
}
queryClient.setQueryData(input.projectConfigQueryKey, {
@@ -72,48 +129,211 @@ export function useProjectConfigImportModel(input: {
repoRoot: input.repoRoot,
});
queryClient.invalidateQueries({ queryKey: ["projects"] });
toast.show(t("settings.project.import.success"), { variant: "success" });
setApplyError(null);
input.onClose();
const appliedSource = registry.get(result.source);
toast.show(
t("settings.project.import.success", {
source: appliedSource?.displayName ?? result.source.kind,
}),
{ variant: "success" },
);
setIntent(null);
},
onError: (error) => {
onError: (cause) => {
setApplyError(
normalizeProjectConfigImportError(
error instanceof Error ? error : new Error(String(error)),
cause instanceof Error ? cause : new Error(String(cause)),
),
);
setApplyErrorRetryAction("apply");
setRetryAction("apply");
},
});
const apply = useCallback(() => {
if (applyMutation.isPending) {
return;
const availability = useProjectConfigImportAvailability({
client: input.client,
serverId: input.serverId,
repoRoot: input.repoRoot,
enabled: input.projectConfigLoaded,
registry,
});
const state = useMemo<ProjectConfigImportState | null>(() => {
if (!intent) {
return null;
}
setApplyError(null);
applyMutation.mutate();
}, [applyMutation]);
const error =
applyError ??
normalizeProjectConfigImportError(
activePreview.data && !activePreview.data.ok
? activePreview.data.error
: activePreview.error,
);
if (error) {
return {
status: "error",
intent,
preview,
error,
retryAction: applyError ? retryAction : "refresh",
};
}
if (preview && applyMutation.isPending) {
return { status: "applying", intent, preview, error: null };
}
if (preview) {
return { status: "ready", intent, preview, error: null };
}
return { status: "loading", intent, preview: null, error: null };
}, [
activePreview.data,
activePreview.error,
applyError,
applyMutation.isPending,
intent,
preview,
retryAction,
]);
const state = useMemo(
() =>
openProjectConfigImport({
intent: input.intent,
preview: input.preview,
isLoading: input.isPreviewLoading,
error: applyError ?? input.previewError,
errorRetryAction: applyError ? applyErrorRetryAction : "refresh",
isApplying: applyMutation.isPending,
}),
[
applyError,
applyErrorRetryAction,
applyMutation.isPending,
input.intent,
input.isPreviewLoading,
input.preview,
input.previewError,
],
return {
sources,
intent,
state,
activeSourceName: activeSource?.displayName ?? null,
availability,
open: (source: ProjectConfigImportSourceRegistration) => {
const nextIntent = createProjectConfigImportIntentFromRegistration({
serverId: input.serverId,
registration: source,
intentId: String(Date.now()),
});
if (nextIntent) {
setIntent(nextIntent);
}
},
close: () => setIntent(null),
refresh: () => {
void activePreview.refetch();
},
apply: () => {
if (!applyMutation.isPending) {
setApplyError(null);
applyMutation.mutate();
}
},
};
}
export function useProjectConfigImportAvailability(input: {
client: DaemonClient | null;
serverId: string | null | undefined;
repoRoot: string | null | undefined;
enabled: boolean;
registry?: ProjectConfigImportSourceRegistry;
}) {
const registry = input.registry ?? projectConfigImportSourceRegistry;
const serverId = input.serverId ?? "";
const repoRoot = input.repoRoot ?? "";
const sources = useAdvertisedProjectConfigImportSources(serverId, registry);
const previews = useProjectConfigImportPreviewQueries({
client: input.client,
serverId,
repoRoot,
sources,
enabled: input.enabled,
});
const availableSources = sources.filter((_, index) => {
const data = previews[index]?.data;
return data?.ok === true && data.status === "available";
});
const availableKinds = new Set(availableSources.map((source) => source.kind));
const availableSourceKeys = new Set(
availableSources.map((source) => stableProjectConfigImportSourceKey(source.source)),
);
return { state, apply };
return {
status: projectConfigImportAvailabilityStatus(availableSources.length),
source: availableSources.length === 1 ? availableSources[0] : null,
sources: availableSources,
availableKinds,
availableSourceKeys,
};
}
function projectConfigImportAvailabilityStatus(count: number): "none" | "one" | "many" {
if (count === 0) {
return "none";
}
return count === 1 ? "one" : "many";
}
function useAdvertisedProjectConfigImportSources(
serverId: string | null | undefined,
registry: ProjectConfigImportSourceRegistry,
): ProjectConfigImportSourceRegistration[] {
const advertised = useSessionStore(
useCallback(
(state) => {
const id = serverId?.trim();
return id
? (state.sessions[id]?.serverInfo?.features?.projectConfigImportSources ??
EMPTY_IMPORT_SOURCES)
: EMPTY_IMPORT_SOURCES;
},
[serverId],
),
);
return useMemo(() => registry.advertised(advertised), [advertised, registry]);
}
function useProjectConfigImportPreviewQueries(input: {
client: DaemonClient | null;
serverId: string;
repoRoot: string;
sources: readonly ProjectConfigImportSourceRegistration[];
enabled: boolean;
}) {
return useFetchQueries<ProjectConfigImportPreviewResult>(
input.sources.map((source) =>
projectConfigImportPreviewQueryInput({
client: input.client,
serverId: input.serverId,
repoRoot: input.repoRoot,
source: source.source,
protocolSource: source.protocolSource,
enabled: input.enabled,
}),
),
);
}
function useProjectConfigImportPreviewQuery(input: {
client: DaemonClient | null;
serverId: string;
repoRoot: string;
source: ProjectConfigImportSourceRegistration["source"] | null;
protocolSource: ProjectConfigImportSource | null;
enabled: boolean;
}) {
return useFetchQuery(projectConfigImportPreviewQueryInput(input));
}
type ProjectConfigImportRetryAction = "refresh" | "apply";
function projectConfigImportApplyFailureRetryAction(
error: ProjectConfigImportVisibleError,
): ProjectConfigImportRetryAction {
return error.code === "stale_source_config" ||
error.code === "stale_project_config" ||
error.code === "nothing_to_import"
? "refresh"
: "apply";
}
function normalizeProjectConfigImportError(
error: ProjectConfigRpcError | Error | null,
): ProjectConfigImportVisibleError | null {
if (!error) {
return null;
}
return error instanceof Error
? { code: "transport", message: error.message || "The host did not respond." }
: error;
}
@@ -26,15 +26,8 @@ import { ExternalLink } from "@/components/ui/external-link";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ConductorIcon } from "@/project-config-import/conductor-icon";
import {
normalizeProjectConfigImportError,
type ProjectConfigImportIntent,
} from "@/project-config-import/project-config-import-model";
import { ProjectConfigImportSheet } from "@/project-config-import/project-config-import-sheet";
import { useProjectConfigImportPreview } from "@/project-config-import/project-config-import-preview";
import { useProjectConfigImportModel } from "@/project-config-import/use-project-config-import-model";
import { useHostFeature } from "@/runtime/host-features";
import type { ProjectConfigImportIntent } from "@/project-config-import/route";
import { ProjectConfigImportSection } from "@/project-config-import/project-config-import-section";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section";
@@ -95,11 +88,13 @@ type ReadProjectConfigData = Awaited<ReturnType<DaemonClient["readProjectConfig"
export interface ProjectSettingsScreenProps {
projectKey: string;
importIntent?: ProjectConfigImportIntent | null;
onImportIntentConsumed?: () => void;
}
export default function ProjectSettingsScreen({
projectKey,
importIntent = null,
onImportIntentConsumed,
}: ProjectSettingsScreenProps) {
const { projects } = useProjects();
const project = useMemo(
@@ -148,6 +143,7 @@ export default function ProjectSettingsScreen({
client={client}
isHostGone={isHostGone}
importIntent={importIntent}
onImportIntentConsumed={onImportIntentConsumed}
/>
);
}
@@ -206,6 +202,7 @@ interface ProjectSettingsBodyProps {
client: DaemonClient;
isHostGone: boolean;
importIntent: ProjectConfigImportIntent | null;
onImportIntentConsumed?: () => void;
}
function ProjectSettingsBody({
@@ -216,12 +213,8 @@ function ProjectSettingsBody({
client,
isHostGone,
importIntent,
onImportIntentConsumed,
}: ProjectSettingsBodyProps) {
const [openImportIntent, setOpenImportIntent] = useState<ProjectConfigImportIntent | null>(null);
const supportsConductorImport = useHostFeature(
selectedHost.serverId,
"projectConfigImportConductor",
);
const queryKey = useMemo(
() => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const,
[selectedHost.serverId, selectedHost.repoRoot],
@@ -248,54 +241,11 @@ function ProjectSettingsBody({
projects: projectIconTargets,
});
const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null;
const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null;
const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null;
const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null;
const importPreviewQuery = useProjectConfigImportPreview({
client,
serverId: selectedHost.serverId,
repoRoot: selectedHost.repoRoot,
source: { kind: "conductor" },
enabled: supportsConductorImport && readQuery.data?.ok === true,
});
useEffect(() => {
if (!importIntent) {
return;
}
if (importIntent.serverId !== selectedHost.serverId) {
return;
}
setOpenImportIntent(importIntent);
}, [importIntent, selectedHost.serverId]);
const readState = resolveProjectConfigReadState(data);
const handleReload = useCallback(() => {
void readQuery.refetch();
}, [readQuery]);
const handleCloseImport = useCallback(() => {
setOpenImportIntent(null);
}, []);
const handleRefreshImport = useCallback(() => {
void importPreviewQuery.refetch();
}, [importPreviewQuery]);
const importModel = useProjectConfigImportModel({
intent: openImportIntent ?? {
serverId: selectedHost.serverId,
source: { kind: "conductor" },
intentId: "closed",
},
repoRoot: selectedHost.repoRoot,
client,
preview: importPreviewQuery.data?.ok ? importPreviewQuery.data : null,
isPreviewLoading: importPreviewQuery.isLoading || importPreviewQuery.isFetching,
previewError: normalizeProjectConfigImportError(
importPreviewQuery.data && !importPreviewQuery.data.ok
? importPreviewQuery.data.error
: importPreviewQuery.error,
),
projectConfigQueryKey: queryKey,
onClose: handleCloseImport,
});
const hasMultipleHosts = hosts.length > 1;
@@ -317,32 +267,40 @@ function ProjectSettingsBody({
{renderContent({
readQuery,
loadedConfig,
loadedRevision,
readError,
loadedConfig: readState.loadedConfig,
loadedRevision: readState.loadedRevision,
readError: readState.readError,
selectedHost,
queryKey,
client,
onReload: handleReload,
hasMultipleHosts,
isHostGone,
importPreviewQuery,
importIntent: openImportIntent,
onOpenImport: setOpenImportIntent,
importIntent,
onImportIntentConsumed,
})}
{openImportIntent ? (
<ProjectConfigImportSheet
visible
state={importModel.state}
onClose={handleCloseImport}
onRefresh={handleRefreshImport}
onApply={importModel.apply}
/>
) : null}
</View>
);
}
function resolveProjectConfigReadState(data: ReadProjectConfigData | undefined): {
loadedConfig: PaseoConfigRaw | null;
loadedRevision: PaseoConfigRevision | null;
readError: ProjectConfigRpcError | null;
} {
if (!data) {
return { loadedConfig: null, loadedRevision: null, readError: null };
}
if (!data.ok) {
return { loadedConfig: null, loadedRevision: null, readError: data.error };
}
return {
loadedConfig: data.config ?? {},
loadedRevision: data.revision,
readError: null,
};
}
interface RenderContentInput {
readQuery: ReturnType<typeof useQuery<ReadProjectConfigData>>;
loadedConfig: PaseoConfigRaw | null;
@@ -354,9 +312,8 @@ interface RenderContentInput {
onReload: () => void;
hasMultipleHosts: boolean;
isHostGone: boolean;
importPreviewQuery: ReturnType<typeof useProjectConfigImportPreview>;
importIntent: ProjectConfigImportIntent | null;
onOpenImport: (intent: ProjectConfigImportIntent | null) => void;
onImportIntentConsumed?: () => void;
}
function renderContent({
@@ -370,9 +327,8 @@ function renderContent({
onReload,
hasMultipleHosts,
isHostGone,
importPreviewQuery,
importIntent,
onOpenImport,
onImportIntentConsumed,
}: RenderContentInput) {
if (readQuery.isLoading) {
return (
@@ -427,9 +383,8 @@ function renderContent({
queryKey={queryKey}
client={client}
onReload={onReload}
importPreviewQuery={importPreviewQuery}
importIntent={importIntent}
onOpenImport={onOpenImport}
onImportIntentConsumed={onImportIntentConsumed}
/>
);
}
@@ -516,9 +471,8 @@ interface ProjectConfigFormProps {
queryKey: readonly [string, string, string];
client: DaemonClient;
onReload: () => void;
importPreviewQuery: ReturnType<typeof useProjectConfigImportPreview>;
importIntent: ProjectConfigImportIntent | null;
onOpenImport: (intent: ProjectConfigImportIntent | null) => void;
onImportIntentConsumed?: () => void;
}
function ProjectConfigForm({
@@ -529,9 +483,8 @@ function ProjectConfigForm({
queryKey,
client,
onReload,
importPreviewQuery,
importIntent,
onOpenImport,
onImportIntentConsumed,
}: ProjectConfigFormProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -736,11 +689,14 @@ function ProjectConfigForm({
info={t("settings.project.worktree.info")}
testID="worktree-group"
>
<ConductorImportRow
previewQuery={importPreviewQuery}
<ProjectConfigImportSection
client={client}
serverId={serverId}
onOpenImport={onOpenImport}
importIntent={importIntent}
repoRoot={repoRoot}
routeIntent={importIntent}
onRouteIntentConsumed={onImportIntentConsumed}
projectConfigLoaded
projectConfigQueryKey={queryKey}
/>
<SettingsSection
@@ -894,62 +850,6 @@ function ResolveSpinnerColor(): string {
return styles.spinnerColor.color;
}
interface ConductorImportRowProps {
previewQuery: ReturnType<typeof useProjectConfigImportPreview>;
serverId: string;
importIntent: ProjectConfigImportIntent | null;
onOpenImport: (intent: ProjectConfigImportIntent | null) => void;
}
function ConductorImportRow({
previewQuery,
serverId,
importIntent,
onOpenImport,
}: ConductorImportRowProps) {
const { t } = useTranslation();
const preview = previewQuery.data?.ok ? previewQuery.data : null;
const shouldShow = preview?.status === "available" || importIntent?.source.kind === "conductor";
const handlePress = useCallback(() => {
onOpenImport({
serverId,
source: { kind: "conductor" },
intentId: String(Date.now()),
});
}, [onOpenImport, serverId]);
if (!shouldShow) {
return null;
}
return (
<SettingsSection
title={t("settings.project.import.rowTitle")}
testID="conductor-import-section"
>
<Pressable
testID="conductor-import-row"
accessibilityRole="button"
accessibilityLabel={t("settings.project.import.rowTitle")}
onPress={handlePress}
style={settingsStyles.card}
>
<View style={styles.importRow}>
<View style={styles.importIconWrap}>
<ConductorIcon size={16} color={styles.iconColor.color} />
</View>
<View style={styles.importText}>
<Text style={settingsStyles.rowTitle}>{t("settings.project.import.rowTitle")}</Text>
<Text style={settingsStyles.rowHint} numberOfLines={2}>
{t("settings.project.import.rowDescription")}
</Text>
</View>
</View>
</Pressable>
</SettingsSection>
);
}
interface ProjectNameEditorProps {
project: ProjectSummary;
client: DaemonClient;
@@ -1483,26 +1383,6 @@ const styles = StyleSheet.create((theme) => ({
iconColor: {
color: theme.colors.foregroundMuted,
},
importRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
padding: theme.spacing[3],
},
importIconWrap: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
importText: {
flex: 1,
minWidth: 0,
},
hostIndicator: {
flexDirection: "row",
alignItems: "center",
+12 -3
View File
@@ -98,7 +98,7 @@ import {
} from "@/screens/settings/host-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import type { ProjectConfigImportIntent } from "@/project-config-import/project-config-import-model";
import type { ProjectConfigImportIntent } from "@/project-config-import/route";
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
@@ -124,7 +124,12 @@ export type SettingsView =
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string; section: HostSectionSlug }
| { kind: "projects" }
| { kind: "project"; projectKey: string; importIntent?: ProjectConfigImportIntent | null };
| {
kind: "project";
projectKey: string;
importIntent?: ProjectConfigImportIntent | null;
onImportIntentConsumed?: () => void;
};
interface SidebarSectionItem {
id: SettingsSectionSlug;
@@ -1387,7 +1392,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
}
if (view.kind === "project") {
return (
<ProjectSettingsScreen projectKey={view.projectKey} importIntent={view.importIntent} />
<ProjectSettingsScreen
projectKey={view.projectKey}
importIntent={view.importIntent}
onImportIntentConsumed={view.onImportIntentConsumed}
/>
);
}
if (view.kind === "section") {
+2 -2
View File
@@ -213,12 +213,12 @@ describe("projects settings routes", () => {
expect(
buildProjectSettingsImportRoute({
projectKey: "remote:github.com/acme/app",
source: "conductor",
source: "fake",
serverId: "server-1",
intentId: "intent 1",
}),
).toBe(
"/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",
);
});
+1 -1
View File
@@ -571,7 +571,7 @@ export function buildProjectSettingsRoute(projectKey: string) {
export function buildProjectSettingsImportRoute(input: {
projectKey: string;
source: "conductor";
source: string;
serverId: string;
intentId: string;
}) {