feat(settings): import existing project setup

This commit is contained in:
Mohamed Boudra
2026-07-17 11:15:10 +02:00
parent a8ebd390fa
commit 8fe11e3bb0
43 changed files with 3464 additions and 48 deletions
@@ -1,12 +1,22 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
import { parseProjectConfigImportIntent } from "@/project-config-import/project-config-import-model";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
const params = useLocalSearchParams<{
projectKey?: string | string[];
importSource?: string | string[];
importServerId?: string | string[];
importIntentId?: string | string[];
}>();
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
const importIntent = useMemo(() => parseProjectConfigImportIntent(params), [params]);
const view = useMemo(
() => ({ kind: "project" as const, projectKey, importIntent }),
[importIntent, projectKey],
);
return <SettingsScreen view={view} />;
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildConductorMigrationCalloutPolicy,
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
@@ -100,4 +101,27 @@ describe("buildWorktreeSetupCalloutPolicy", () => {
testID: "worktree-setup-callout-project-1",
});
});
it("builds a host-bound Conductor migration route with a single-use intent", () => {
expect(
buildConductorMigrationCalloutPolicy(
{
serverId: "server-1",
projectKey: "remote:github.com/acme/app",
repoRoot: "/repo/project-1",
},
"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.",
actionLabel: "Review migration",
projectSettingsRoute:
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp?importSource=conductor&importServerId=server-1&importIntentId=intent-1",
testID: "worktree-setup-callout-remote:github.com/acme/app",
});
});
});
@@ -1,6 +1,6 @@
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import { buildProjectSettingsImportRoute, buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
projectId: string;
@@ -73,6 +73,29 @@ export function buildWorktreeSetupCalloutPolicy(
};
}
export function buildConductorMigrationCalloutPolicy(
project: ActiveGitWorkspaceProject,
intentId: string,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
return {
id: calloutKey,
dismissalKey: calloutKey,
priority: 100,
title: i18n.t("sidebar.worktreeSetup.conductorTitle"),
description: i18n.t("sidebar.worktreeSetup.conductorDescription"),
actionLabel: i18n.t("sidebar.worktreeSetup.reviewMigration"),
projectSettingsRoute: buildProjectSettingsImportRoute({
projectKey: project.projectKey,
source: "conductor",
serverId: project.serverId,
intentId,
}),
testID: `worktree-setup-callout-${project.projectKey}`,
};
}
function hasSetupCommands(config: PaseoConfigRaw): boolean {
const setup = config.worktree?.setup;
if (typeof setup === "string") {
@@ -3,10 +3,13 @@ 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 { 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,
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
@@ -20,14 +23,17 @@ 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(() => {
if (!activeProject) {
return;
}
router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute);
});
const openProjectSettings = useStableEvent(
(route: ReturnType<typeof buildWorktreeSetupCalloutPolicy>["projectSettingsRoute"]) => {
router.navigate(route);
},
);
const readQuery = useQuery({
queryKey: ["project-config", activeProject?.serverId ?? "", activeProject?.repoRoot ?? ""],
@@ -41,13 +47,25 @@ export function WorktreeSetupCalloutSource() {
retry: false,
});
const calloutPolicy = useMemo(
() =>
activeProject && shouldShowWorktreeSetupCallout(readQuery.data)
? buildWorktreeSetupCalloutPolicy(activeProject)
: null,
[activeProject, readQuery.data],
);
const shouldConsiderSetup = activeProject && shouldShowWorktreeSetupCallout(readQuery.data);
const importPreviewQuery = useProjectConfigImportPreview({
client,
serverId: activeProject?.serverId ?? "",
repoRoot: activeProject?.repoRoot ?? "",
source: { kind: "conductor" },
enabled: Boolean(shouldConsiderSetup && supportsConductorImport),
});
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()));
}
return buildWorktreeSetupCalloutPolicy(activeProject);
}, [activeProject, importPreviewQuery.data, readQuery.data, supportsConductorImport]);
useEffect(() => {
if (!calloutPolicy) {
@@ -61,7 +79,11 @@ export function WorktreeSetupCalloutSource() {
title: calloutPolicy.title,
description: calloutPolicy.description,
actions: [
{ label: calloutPolicy.actionLabel, onPress: openProjectSettings, variant: "primary" },
{
label: calloutPolicy.actionLabel,
onPress: () => openProjectSettings(calloutPolicy.projectSettingsRoute),
variant: "primary",
},
],
testID: calloutPolicy.testID,
});
+24
View File
@@ -867,6 +867,9 @@ export const ar: TranslationResources = {
description:
"أضف أوامر الإعداد حتى تتمكن أشجار العمل الجديدة من تثبيت التبعيات وإعداد نفسها تلقائيًا.",
openProjectSettings: "افتح إعدادات المشروع",
conductorTitle: "Conductor setup found",
conductorDescription: "Import its workspace setup and run scripts into Paseo.",
reviewMigration: "Review migration",
},
project: {
actions: {
@@ -2064,6 +2067,27 @@ export const ar: TranslationResources = {
teardown: "هدم",
teardownAccessibility: "أوامر هدم شجرة العمل",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
needsAttention: "Needs attention",
notSupported: "Not supported",
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},
},
scripts: {
title: "البرامج النصية",
info: "خدمات طويلة الأمد وأوامر لمرة واحدة يمكنك إطلاقها من أي وكيل في هذا المشروع",
+24
View File
@@ -878,6 +878,9 @@ 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.",
reviewMigration: "Review migration",
},
project: {
actions: {
@@ -2078,6 +2081,27 @@ export const en = {
teardown: "Teardown",
teardownAccessibility: "Worktree teardown commands",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
needsAttention: "Needs attention",
notSupported: "Not supported",
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},
},
scripts: {
title: "Scripts",
info: "Long-running services and one-off commands you can launch from any agent in this project",
+24
View File
@@ -898,6 +898,9 @@ 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.",
reviewMigration: "Review migration",
},
project: {
actions: {
@@ -2116,6 +2119,27 @@ export const es: TranslationResources = {
teardown: "Demoler",
teardownAccessibility: "Comandos de desmontaje del árbol de trabajo",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
needsAttention: "Needs attention",
notSupported: "Not supported",
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},
},
scripts: {
title: "Scripts",
info: "Servicios de larga duración y comandos únicos que puede iniciar desde cualquier agente en este proyecto",
+24
View File
@@ -896,6 +896,9 @@ 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.",
reviewMigration: "Review migration",
},
project: {
actions: {
@@ -2119,6 +2122,27 @@ export const fr: TranslationResources = {
teardown: "Démolir",
teardownAccessibility: "Commandes de démontage de Worktree",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
needsAttention: "Needs attention",
notSupported: "Not supported",
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},
},
scripts: {
title: "Scripts",
info: "Services de longue durée et commandes ponctuelles que vous pouvez lancer à partir de n'importe quel agent de ce projet",
+24
View File
@@ -880,6 +880,9 @@ export const ja: TranslationResources = {
description:
"新しいワークツリーが依存関係をインストールして自動的に準備できるようにセットアップコマンドを追加してください。",
openProjectSettings: "プロジェクト設定を開く",
conductorTitle: "Conductor setup found",
conductorDescription: "Import its workspace setup and run scripts into Paseo.",
reviewMigration: "Review migration",
},
project: {
actions: {
@@ -2089,6 +2092,27 @@ export const ja: TranslationResources = {
teardown: "削除時",
teardownAccessibility: "ワークツリー削除時のコマンド",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
needsAttention: "Needs attention",
notSupported: "Not supported",
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},
},
scripts: {
title: "スクリプト",
info: "このプロジェクトのどのエージェントからでも起動できる、長時間実行サービスと単発コマンド",
+24
View File
@@ -890,6 +890,9 @@ 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.",
reviewMigration: "Review migration",
},
project: {
actions: {
@@ -2102,6 +2105,27 @@ export const ptBR: TranslationResources = {
teardown: "Desmontagem",
teardownAccessibility: "Comandos de desmontagem do worktree",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
needsAttention: "Needs attention",
notSupported: "Not supported",
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},
},
scripts: {
title: "Scripts",
info: "Serviços contínuos e comandos avulsos que você pode iniciar de qualquer agente neste projeto",
+24
View File
@@ -889,6 +889,9 @@ export const ru: TranslationResources = {
description:
"Добавьте команды настройки, чтобы новые рабочие деревья могли автоматически устанавливать зависимости и готовиться.",
openProjectSettings: "Открыть настройки проекта",
conductorTitle: "Conductor setup found",
conductorDescription: "Import its workspace setup and run scripts into Paseo.",
reviewMigration: "Review migration",
},
project: {
actions: {
@@ -2107,6 +2110,27 @@ export const ru: TranslationResources = {
teardown: "Срывать",
teardownAccessibility: "Команды разрушения рабочего дерева",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
needsAttention: "Needs attention",
notSupported: "Not supported",
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},
},
scripts: {
title: "Скрипты",
info: "Долгоработающие службы и одноразовые команды, которые можно запускать из любого агента в этом проекте.",
+24
View File
@@ -860,6 +860,9 @@ 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.",
reviewMigration: "Review migration",
},
project: {
actions: {
@@ -2040,6 +2043,27 @@ export const zhCN: TranslationResources = {
teardown: "Teardown",
teardownAccessibility: "Worktree teardown 命令",
},
import: {
rowTitle: "Import from Conductor",
rowDescription: "Review workspace setup and run scripts before writing paseo.json.",
sheetTitle: "Import from {{source}}",
sources: "Source files",
willImport: "Will import",
needsAttention: "Needs attention",
notSupported: "Not supported",
import: "Import",
importing: "Importing...",
refreshPreview: "Refresh preview",
success: "Conductor settings imported",
errorTitle: "Couldn't import settings",
errors: {
notFound: "No Conductor project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The Conductor config changed. Refresh the preview before importing.",
staleProject: "paseo.json changed. Refresh the preview before importing.",
nothing: "There is nothing new to import.",
},
},
scripts: {
title: "Scripts",
info: "可从此 Project 中任意 Agent 启动的长期服务和一次性命令",
@@ -0,0 +1,33 @@
import Svg, { Path } from "react-native-svg";
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",
"M36.5977 63.6992H18.7988V37.251H36.5977C37.584 37.2511 38.3848 38.0517 38.3848 39.0381V61.9121C38.3846 62.8984 37.5839 63.6991 36.5977 63.6992Z",
"M4.57422 100.297H22.373V73.8486H4.57422C3.58785 73.8488 2.78711 74.6493 2.78711 75.6357V98.5098C2.78725 99.496 3.58794 100.297 4.57422 100.297Z",
"M36.5977 100.297H18.7988V73.8486H36.5977C37.584 73.8488 38.3848 74.6493 38.3848 75.6357V98.5098C38.3846 99.496 37.5839 100.297 36.5977 100.297Z",
"M4.57422 136.896H22.373V110.447H4.57422C3.58785 110.447 2.78711 111.248 2.78711 112.234V135.108C2.78725 136.095 3.58794 136.895 4.57422 136.896Z",
"M36.5977 136.896H18.7988V110.447H36.5977C37.584 110.447 38.3848 111.248 38.3848 112.234V135.108C38.3846 136.095 37.5839 136.895 36.5977 136.896Z",
"M22.873 173.493H40.6719V147.045H22.873C21.8867 147.045 21.0859 147.846 21.0859 148.832V171.706C21.0861 172.692 21.8868 173.493 22.873 173.493Z",
"M37.0967 173.493V147.045H58.9707V173.493H37.0967Z",
"M55.3955 173.493V147.045H77.2695V173.493H55.3955Z",
"M91.4941 173.493H73.6953V147.045H91.4941C92.4805 147.045 93.2812 147.846 93.2812 148.832V171.706C93.2811 172.692 92.4804 173.493 91.4941 173.493Z",
"M77.7695 136.896H95.5684V110.447H77.7695C76.7832 110.447 75.9824 111.248 75.9824 112.234V135.108C75.9826 136.095 76.7833 136.895 77.7695 136.896Z",
"M109.793 136.896H91.9941V110.447H109.793C110.779 110.447 111.58 111.248 111.58 112.234V135.108C111.58 136.095 110.779 136.895 109.793 136.896Z",
"M22.873 27.1006H40.6719V0.652344H22.873C21.8867 0.652488 21.0859 1.45305 21.0859 2.43945V25.3135C21.0861 26.2998 21.8868 27.1004 22.873 27.1006Z",
"M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z",
"M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z",
"M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z",
"M109.793 27.1006H91.9941V0.652344H109.793C110.779 0.652488 111.58 1.45305 111.58 2.43945V25.3135C111.58 26.2998 110.779 27.1004 109.793 27.1006Z",
"M77.7695 63.6992H95.5684V37.251H77.7695C76.7832 37.2511 75.9824 38.0517 75.9824 39.0381V61.9121C75.9826 62.8984 76.7833 63.6991 77.7695 63.6992Z",
"M109.793 63.6992H91.9941V37.251H109.793C110.779 37.2511 111.58 38.0517 111.58 39.0381V61.9121C111.58 62.8984 110.779 63.6991 109.793 63.6992Z",
] as const;
export function ConductorIcon({ size = 18, color = "#282423" }: { size?: number; color?: string }) {
return (
<Svg width={size} height={size} viewBox="0 0 115 174" fill="none">
{PATHS.map((path) => (
<Path key={path} d={path} fill={color} stroke={color} />
))}
</Svg>
);
}
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import {
normalizeProjectConfigImportError,
openProjectConfigImport,
parseProjectConfigImportIntent,
projectConfigImportApplyFailureRetryAction,
projectConfigImportCanApply,
projectConfigImportNeedsRefresh,
projectConfigImportSourceFeature,
} from "./project-config-import-model";
describe("project config import intent", () => {
it("parses a Conductor host-bound route intent", () => {
expect(
parseProjectConfigImportIntent({
importSource: "conductor",
importServerId: "server-1",
importIntentId: "intent-1",
}),
).toEqual({
serverId: "server-1",
source: { kind: "conductor" },
intentId: "intent-1",
});
});
it("rejects missing or unknown sources", () => {
expect(
parseProjectConfigImportIntent({
importSource: "other",
importServerId: "server-1",
importIntentId: "intent-1",
}),
).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);
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,
}),
).toEqual({
status: "error",
intent,
preview,
error: { code: "stale_source_config", source: { kind: "conductor" } },
retryAction: "refresh",
});
});
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: "write_failed" })).toBe(false);
expect(projectConfigImportApplyFailureRetryAction({ code: "write_failed" })).toBe("apply");
expect(
projectConfigImportApplyFailureRetryAction({
code: "stale_source_config",
source: { kind: "conductor" },
}),
).toBe("refresh");
});
});
@@ -0,0 +1,158 @@
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";
}
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;
}
@@ -0,0 +1,36 @@
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,265 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
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 { 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";
interface ProjectConfigImportSheetProps {
visible: boolean;
state: ProjectConfigImportState;
onClose: () => void;
onRefresh: () => void;
onApply: () => void;
}
export function ProjectConfigImportSheet({
visible,
state,
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 preview = state.preview;
const visibleError = state.status === "error" ? state.error : null;
const needsRefresh = visibleError ? projectConfigImportNeedsRefresh(visibleError) : false;
const retryAction = state.status === "error" ? state.retryAction : "refresh";
const handleRetry = retryAction === "apply" ? onApply : onRefresh;
const canImport = projectConfigImportCanApply(state);
const isLoading = state.status === "loading";
const isApplying = state.status === "applying";
return (
<AdaptiveModalSheet
visible={visible}
header={header}
onClose={onClose}
testID="project-config-import-sheet"
desktopMaxWidth={640}
>
<View style={styles.content}>
{isLoading ? (
<View style={styles.loading}>
<LoadingSpinner color={styles.spinnerColor.color} />
</View>
) : null}
{visibleError ? (
<Alert
testID="project-config-import-error"
variant="error"
title={t("settings.project.import.errorTitle")}
description={projectConfigImportErrorText(visibleError, t)}
>
{needsRefresh ? (
<Button
testID="project-config-import-refresh"
onPress={onRefresh}
variant="outline"
size="sm"
>
{t("settings.project.import.refreshPreview")}
</Button>
) : (
<Button
testID="project-config-import-retry"
onPress={handleRetry}
variant="outline"
size="sm"
>
{t("settings.project.actions.tryAgain")}
</Button>
)}
<Button
testID="project-config-import-cancel-error"
onPress={onClose}
variant="ghost"
size="sm"
>
{t("settings.project.actions.cancel")}
</Button>
</Alert>
) : null}
{preview ? <PreviewBody preview={preview} /> : null}
<View style={styles.footer}>
<Button testID="project-config-import-cancel" onPress={onClose} variant="ghost" size="md">
{t("settings.project.actions.cancel")}
</Button>
<Button
testID="project-config-import-apply"
onPress={onApply}
variant="default"
size="md"
disabled={!canImport || isApplying}
loading={isApplying}
>
{isApplying
? t("settings.project.import.importing")
: t("settings.project.import.import")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
function PreviewBody({ preview }: { preview: ProjectConfigImportPreview }) {
const { t } = useTranslation();
const grouped = groupPreviewItems(preview);
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} />
</View>
);
}
function ItemGroup({
title,
items,
}: {
title: string;
items: ProjectConfigImportPreview["items"];
}) {
if (items.length === 0) {
return null;
}
return (
<View style={styles.group}>
<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}
>
<Text style={settingsStyles.rowTitle}>{item.label}</Text>
{item.detail ? <Text style={styles.commandText}>{item.detail}</Text> : null}
</View>
))}
</View>
</View>
);
}
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 projectConfigImportErrorText(
error: ProjectConfigImportVisibleError,
t: TFunction,
): string {
switch (error.code) {
case "transport":
return error.message;
case "source_config_not_found":
return t("settings.project.import.errors.notFound");
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");
case "stale_project_config":
return t("settings.project.import.errors.staleProject");
case "nothing_to_import":
return t("settings.project.import.errors.nothing");
case "write_failed":
return t("settings.project.writeFailures.failedDescription");
case "invalid_project_config":
return t("settings.project.readFailures.invalidDescription");
case "project_not_found":
return t("settings.project.readFailures.missingSingleHost");
}
}
const styles = StyleSheet.create((theme) => ({
content: {
gap: theme.spacing[4],
},
loading: {
minHeight: 120,
alignItems: "center",
justifyContent: "center",
},
preview: {
gap: theme.spacing[4],
},
sourceRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
group: {
gap: theme.spacing[2],
},
groupTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
rowWithBorder: {
...settingsStyles.row,
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
commandText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: theme.fontFamily.mono,
},
footer: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
spinnerColor: {
color: theme.colors.foregroundMuted,
},
iconColor: {
color: theme.colors.foreground,
},
}));
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useMemo, 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 ProjectConfigImportIntent,
type ProjectConfigImportRetryAction,
type ProjectConfigImportState,
type ProjectConfigImportVisibleError,
} from "./project-config-import-model";
export interface ProjectConfigImportModel {
state: ProjectConfigImportState;
apply: () => void;
}
export function useProjectConfigImportModel(input: {
intent: ProjectConfigImportIntent;
repoRoot: string;
client: DaemonClient;
preview: ProjectConfigImportPreview | null;
isPreviewLoading: boolean;
previewError: ProjectConfigImportVisibleError | null;
projectConfigQueryKey: readonly [string, string, string];
onClose: () => void;
}): ProjectConfigImportModel {
const { t } = useTranslation();
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(":");
useEffect(() => {
setApplyError(null);
}, [previewRevisionKey]);
const applyMutation = useMutation({
mutationFn: async () => {
if (!input.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,
});
},
onSuccess: (result) => {
if (!result.ok) {
setApplyError(result.error);
setApplyErrorRetryAction(projectConfigImportApplyFailureRetryAction(result.error));
return;
}
queryClient.setQueryData(input.projectConfigQueryKey, {
ok: true,
config: result.config,
revision: result.revision,
requestId: "import-cache",
repoRoot: input.repoRoot,
});
queryClient.invalidateQueries({ queryKey: ["projects"] });
toast.show(t("settings.project.import.success"), { variant: "success" });
setApplyError(null);
input.onClose();
},
onError: (error) => {
setApplyError(
normalizeProjectConfigImportError(
error instanceof Error ? error : new Error(String(error)),
),
);
setApplyErrorRetryAction("apply");
},
});
const apply = useCallback(() => {
if (applyMutation.isPending) {
return;
}
setApplyError(null);
applyMutation.mutate();
}, [applyMutation]);
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 { state, apply };
}
@@ -26,6 +26,15 @@ 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 { SettingsTextAreaCard } from "@/components/settings-textarea";
import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section";
@@ -85,9 +94,13 @@ type ReadProjectConfigData = Awaited<ReturnType<DaemonClient["readProjectConfig"
export interface ProjectSettingsScreenProps {
projectKey: string;
importIntent?: ProjectConfigImportIntent | null;
}
export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) {
export default function ProjectSettingsScreen({
projectKey,
importIntent = null,
}: ProjectSettingsScreenProps) {
const { projects } = useProjects();
const project = useMemo(
() => projects.find((entry) => entry.projectKey === projectKey),
@@ -96,15 +109,22 @@ export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScr
const editableHosts = useMemo(() => filterEditableHosts(project), [project]);
const [selectedServerId, setSelectedServerId] = useState<string>(
() => editableHosts[0]?.serverId ?? "",
() =>
(importIntent && editableHosts.some((host) => host.serverId === importIntent.serverId)
? importIntent.serverId
: editableHosts[0]?.serverId) ?? "",
);
useEffect(() => {
if (importIntent && editableHosts.some((host) => host.serverId === importIntent.serverId)) {
setSelectedServerId(importIntent.serverId);
return;
}
const stillValid = editableHosts.some((host) => host.serverId === selectedServerId);
if (!stillValid) {
setSelectedServerId(editableHosts[0]?.serverId ?? "");
}
}, [editableHosts, selectedServerId]);
}, [editableHosts, importIntent, selectedServerId]);
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
const isHostGone =
@@ -127,6 +147,7 @@ export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScr
onSelectHost={setSelectedServerId}
client={client}
isHostGone={isHostGone}
importIntent={importIntent}
/>
);
}
@@ -184,6 +205,7 @@ interface ProjectSettingsBodyProps {
onSelectHost: (serverId: string) => void;
client: DaemonClient;
isHostGone: boolean;
importIntent: ProjectConfigImportIntent | null;
}
function ProjectSettingsBody({
@@ -193,7 +215,13 @@ function ProjectSettingsBody({
onSelectHost,
client,
isHostGone,
importIntent,
}: 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],
@@ -223,10 +251,51 @@ function ProjectSettingsBody({
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 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;
@@ -257,7 +326,19 @@ function ProjectSettingsBody({
onReload: handleReload,
hasMultipleHosts,
isHostGone,
importPreviewQuery,
importIntent: openImportIntent,
onOpenImport: setOpenImportIntent,
})}
{openImportIntent ? (
<ProjectConfigImportSheet
visible
state={importModel.state}
onClose={handleCloseImport}
onRefresh={handleRefreshImport}
onApply={importModel.apply}
/>
) : null}
</View>
);
}
@@ -273,6 +354,9 @@ interface RenderContentInput {
onReload: () => void;
hasMultipleHosts: boolean;
isHostGone: boolean;
importPreviewQuery: ReturnType<typeof useProjectConfigImportPreview>;
importIntent: ProjectConfigImportIntent | null;
onOpenImport: (intent: ProjectConfigImportIntent | null) => void;
}
function renderContent({
@@ -286,6 +370,9 @@ function renderContent({
onReload,
hasMultipleHosts,
isHostGone,
importPreviewQuery,
importIntent,
onOpenImport,
}: RenderContentInput) {
if (readQuery.isLoading) {
return (
@@ -335,10 +422,14 @@ function renderContent({
key={formKey}
baseConfig={loadedConfig}
revision={loadedRevision}
serverId={selectedHost.serverId}
repoRoot={selectedHost.repoRoot}
queryKey={queryKey}
client={client}
onReload={onReload}
importPreviewQuery={importPreviewQuery}
importIntent={importIntent}
onOpenImport={onOpenImport}
/>
);
}
@@ -420,19 +511,27 @@ function errorToDetail(error: unknown): string | null {
interface ProjectConfigFormProps {
baseConfig: PaseoConfigRaw;
revision: PaseoConfigRevision | null;
serverId: string;
repoRoot: string;
queryKey: readonly [string, string, string];
client: DaemonClient;
onReload: () => void;
importPreviewQuery: ReturnType<typeof useProjectConfigImportPreview>;
importIntent: ProjectConfigImportIntent | null;
onOpenImport: (intent: ProjectConfigImportIntent | null) => void;
}
function ProjectConfigForm({
baseConfig,
revision,
serverId,
repoRoot,
queryKey,
client,
onReload,
importPreviewQuery,
importIntent,
onOpenImport,
}: ProjectConfigFormProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -637,6 +736,13 @@ function ProjectConfigForm({
info={t("settings.project.worktree.info")}
testID="worktree-group"
>
<ConductorImportRow
previewQuery={importPreviewQuery}
serverId={serverId}
onOpenImport={onOpenImport}
importIntent={importIntent}
/>
<SettingsSection
title={t("settings.project.worktree.setup")}
testID="worktree-setup-section"
@@ -788,6 +894,62 @@ 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;
@@ -1321,6 +1483,26 @@ 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",
+5 -2
View File
@@ -98,6 +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 { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
@@ -123,7 +124,7 @@ export type SettingsView =
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string; section: HostSectionSlug }
| { kind: "projects" }
| { kind: "project"; projectKey: string };
| { kind: "project"; projectKey: string; importIntent?: ProjectConfigImportIntent | null };
interface SidebarSectionItem {
id: SettingsSectionSlug;
@@ -1385,7 +1386,9 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
return <ProjectsScreen view={view} />;
}
if (view.kind === "project") {
return <ProjectSettingsScreen projectKey={view.projectKey} />;
return (
<ProjectSettingsScreen projectKey={view.projectKey} importIntent={view.importIntent} />
);
}
if (view.kind === "section") {
switch (view.section) {
@@ -9,6 +9,7 @@ import {
resolveKnownHostRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildProjectSettingsImportRoute,
buildProjectSettingsRoute,
buildProjectsSettingsRoute,
decodeFilePathFromPathSegment,
@@ -208,6 +209,19 @@ describe("projects settings routes", () => {
);
});
it("buildProjectSettingsImportRoute includes the import source, host, and intent", () => {
expect(
buildProjectSettingsImportRoute({
projectKey: "remote:github.com/acme/app",
source: "conductor",
serverId: "server-1",
intentId: "intent 1",
}),
).toBe(
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp?importSource=conductor&importServerId=server-1&importIntentId=intent+1",
);
});
it("project keys round-trip through decodeURIComponent", () => {
const projectKey = "remote:github.com/acme/app";
const route = buildProjectSettingsRoute(projectKey);
+15
View File
@@ -568,3 +568,18 @@ export function buildProjectSettingsRoute(projectKey: string) {
}
return `/settings/projects/${encodeSegment(normalized)}` as const;
}
export function buildProjectSettingsImportRoute(input: {
projectKey: string;
source: "conductor";
serverId: string;
intentId: string;
}) {
const base = buildProjectSettingsRoute(input.projectKey);
const query = new URLSearchParams({
importSource: input.source,
importServerId: input.serverId,
importIntentId: input.intentId,
});
return `${base}?${query.toString()}` as const;
}