Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 153 additions & 28 deletions apps/mobile/src/features/projects/AddProjectScreen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import * as NodeModule from "node:module";
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import { workspaceDetailRouter } from "../layout/workspace/workspace-detail-selection";
import type { NavigationState } from "@react-navigation/native";
import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts";
import {
AuthOrchestrationOperateScope,
AuthSourceControlWriteScope,
SourceControlProviderKind,
SourceControlRepositoryError,
type ProjectCloneExistingDestination,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
import { act, createElement, isValidElement, type ReactNode } from "react";
Expand All @@ -26,6 +32,11 @@ const state = vi.hoisted(() => ({
createResult: null as Promise<unknown> | null,
createNewResult: null as Promise<unknown> | null,
startCloneResult: null as Promise<unknown> | null,
cloneInputs: [] as {
destinationPath: string;
existingDestination?: ProjectCloneExistingDestination;
}[],
canResolveCloneConflicts: false,
connectionStates: {} as Record<string, EnvironmentConnectionPhase>,
newProjectsRoot: null as string | null,
waitForProject: vi.fn(),
Expand Down Expand Up @@ -112,7 +123,10 @@ vi.mock("../../state/entities", () => ({
[
"environment",
{
environment: { platform: { os: "linux" }, capabilities: {} },
environment: {
platform: { os: "linux" },
capabilities: { projectCloneExistingDestination: state.canResolveCloneConflicts },
},
newProjectsRoot: state.newProjectsRoot,
settings: { addProjectBaseDirectory: state.baseDirectory },
},
Expand Down Expand Up @@ -143,7 +157,12 @@ vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} }));
vi.mock("../../state/sourceControl", () => ({
sourceControlEnvironment: {
discovery: (input: unknown) => input,
startProjectClone: async ({ input }: { input: { destinationPath: string } }) => {
startProjectClone: async ({
input,
}: {
input: { destinationPath: string; existingDestination?: ProjectCloneExistingDestination };
}) => {
state.cloneInputs.push(input);
if (state.startCloneResult) return state.startCloneResult;
NodeFS.mkdirSync(input.destinationPath);
return AsyncResult.success(undefined);
Expand Down Expand Up @@ -208,6 +227,32 @@ function findAction(
return findAction(node.props.children, label, includeDisabled);
}

function findPathInput(
node: ReactNode,
): { value: string; onChangeText: (value: string) => void; onSubmit: () => void } | null {
if (Array.isArray(node)) {
for (const child of node) {
const input = findPathInput(child);
if (input) return input;
}
return null;
}
if (
!isValidElement<{
value?: string;
onChangeText?: (value: string) => void;
onSubmit?: () => void;
children?: ReactNode;
}>(node)
) {
return null;
}
const { value, onChangeText, onSubmit } = node.props;
if (typeof value === "string" && onChangeText && onSubmit)
return { value, onChangeText, onSubmit };
return findPathInput(node.props.children);
}

const mountedRoots: ReturnType<typeof createRoot>[] = [];
afterEach(() => {
for (const root of mountedRoots.splice(0)) act(() => root.unmount());
Expand Down Expand Up @@ -658,31 +703,6 @@ describe("local project navigation", () => {
});

describe("local folder path", () => {
function findPathInput(
node: ReactNode,
): { value: string; onChangeText: (value: string) => void } | null {
if (Array.isArray(node)) {
for (const child of node) {
const input = findPathInput(child);
if (input) return input;
}
return null;
}
if (
!isValidElement<{
value?: string;
onChangeText?: (value: string) => void;
onSubmit?: () => void;
children?: ReactNode;
}>(node)
) {
return null;
}
const { value, onChangeText, onSubmit } = node.props;
if (typeof value === "string" && onChangeText && onSubmit) return { value, onChangeText };
return findPathInput(node.props.children);
}

function mountLocalFolder() {
state.scopes = new Set([AuthOrchestrationOperateScope]);
state.baseDirectory = "/work";
Expand Down Expand Up @@ -756,3 +776,108 @@ describe("local folder path", () => {
expect(screen.path).toBe("~/code/app");
});
});

describe("clone destination conflicts", () => {
beforeEach(async () => {
state.baseDirectory = await NodeFSP.mkdtemp(
NodePath.join(NodeOS.tmpdir(), "t3-mobile-conflict-"),
);
await NodeFSP.mkdir(NodePath.join(state.baseDirectory, "repo"));
await NodeFSP.writeFile(NodePath.join(state.baseDirectory, "repo", "local.txt"), "keep");
state.scopes = new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope]);
state.canResolveCloneConflicts = true;
state.cloneInputs = [];
state.startCloneResult = Promise.resolve(
AsyncResult.failure(
Cause.fail(
new SourceControlRepositoryError({
provider: SourceControlProviderKind.make("unknown"),
operation: "cloneRepository",
detail: "Destination path already exists and is not empty.",
reason: "destination_exists",
}),
),
),
);
state.dispatch.mockReset();
state.navigationState = undefined;
state.waitForProject.mockResolvedValue({ environmentId: "environment", id: "project" });
state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }];
});

afterEach(async () => {
state.canResolveCloneConflicts = false;
state.startCloneResult = null;
await NodeFSP.rm(state.baseDirectory, { recursive: true, force: true });
});

it.each(["Cancel", "Combine", "Replace"])(
"requires an explicit %s choice after the occupied destination fails",
async (choice) => {
let screen: ReactNode;
renderScreen(
() =>
(screen = AddProjectDestinationScreen({
environmentId: "environment",
remoteUrl: "https://example.com/repo.git",
repositoryName: "repo",
prompt: "keep my prompt",
})),
);
await act(async () => {
await findAction(screen, "Clone project")!();
});
await act(async () => {
await findPathInput(screen)!.onSubmit();
});
expect(state.cloneInputs).toHaveLength(1);
const resolve = findAction(screen, choice);
expect(resolve).not.toBeNull();
state.startCloneResult = Promise.resolve(AsyncResult.success(undefined));
await act(async () => {
await resolve!();
});
expect(
await NodeFSP.readFile(NodePath.join(state.baseDirectory, "repo", "local.txt"), "utf8"),
).toBe("keep");
if (choice === "Cancel") {
expect(state.cloneInputs).toHaveLength(1);
expect(state.dispatch).not.toHaveBeenCalled();
expect(findAction(screen, "Clone project")).not.toBeNull();
} else {
expect(state.cloneInputs).toHaveLength(2);
expect(state.cloneInputs[1]).toMatchObject({
destinationPath: NodePath.join(state.baseDirectory, "repo"),
existingDestination: choice.toLowerCase(),
});
expect(state.dispatch).toHaveBeenCalled();
}
},
);

it.each(["older server", "unrelated failure"])(
"keeps resolution choices unavailable for an %s",
async (failure) => {
state.canResolveCloneConflicts = failure !== "older server";
if (failure === "unrelated failure")
state.startCloneResult = Promise.resolve(
AsyncResult.failure(Cause.fail(new Error("Access denied"))),
);
let screen: ReactNode;
renderScreen(
() =>
(screen = AddProjectDestinationScreen({
environmentId: "environment",
remoteUrl: "https://example.com/repo.git",
repositoryName: "repo",
})),
);
await act(async () => {
await findAction(screen, "Clone project")!();
});
expect(findAction(screen, "Replace")).toBeNull();
expect(findAction(screen, "Combine")).toBeNull();
expect(state.cloneInputs).toHaveLength(1);
},
);
});
89 changes: 80 additions & 9 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import {
ProjectId,
resolveEnvironmentMachineKind,
SourceControlProviderKind,
SourceControlRepositoryError,
type ProjectCloneExistingDestination,
} from "@t3tools/contracts";
import {
StackActions,
Expand All @@ -73,6 +75,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Arr from "effect/Array";
import * as Cause from "effect/Cause";
import * as Order from "effect/Order";
import * as Schema from "effect/Schema";
import { AsyncResult } from "effect/reactivity";
import { cn } from "../../lib/cn";
import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";
Expand Down Expand Up @@ -107,6 +110,8 @@ import {
waitForComposerDraftsLoaded,
} from "../../state/use-composer-drafts";

const isRepositoryError = Schema.is(SourceControlRepositoryError);

interface EnvironmentOption {
readonly environmentId: EnvironmentId;
readonly label: string;
Expand Down Expand Up @@ -1383,6 +1388,11 @@ export function AddProjectDestinationScreen(props: {
AuthOrchestrationOperateScope,
);
const canCloneProject = canWriteSourceControl && canCreateProject;
const serverConfigs = useServerConfigs();
const canResolveConflict =
environment !== null &&
serverConfigs.get(environment.environmentId)?.environment.capabilities
.projectCloneExistingDestination === true;
const remoteUrl = stringParam(props.remoteUrl);
const repositoryTitle = stringParam(props.repositoryTitle);
const prompt = stringParam(props.prompt);
Expand All @@ -1399,15 +1409,28 @@ export function AddProjectDestinationScreen(props: {
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [conflict, setConflict] = useState<{
readonly environmentId: EnvironmentId;
readonly pathInput: string;
readonly remoteUrl: string;
} | null>(null);
const hasConflict =
conflict !== null &&
environment !== null &&
conflict.environmentId === environment.environmentId &&
conflict.pathInput === pathInput &&
conflict.remoteUrl === remoteUrl &&
canResolveConflict;

const submitPath = async () => {
const submitPath = async (existingDestination?: ProjectCloneExistingDestination) => {
if (
!environment ||
!readEnvironmentScope(environment.environmentId, AuthSourceControlWriteScope) ||
!readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope) ||
!remoteUrl ||
isBrowseNavigating ||
isSubmitting
isSubmitting ||
(existingDestination ? !hasConflict : hasConflict)
) {
return;
}
Expand All @@ -1434,10 +1457,20 @@ export function AddProjectDestinationScreen(props: {
createdAt: new Date().toISOString(),
remoteUrl,
destinationPath: resolved.path,
existingDestination,
},
});
if (AsyncResult.isFailure(startResult)) {
setError(errorMessage(Cause.squash(startResult.cause)));
const failure = Cause.squash(startResult.cause);
if (
canResolveConflict &&
isRepositoryError(failure) &&
failure.reason === "destination_exists"
) {
setConflict({ environmentId: environment.environmentId, pathInput, remoteUrl });
} else {
setError(errorMessage(failure));
}
} else {
void suggestAppAccessForRemote(checkAppAccess, environment.environmentId, remoteUrl);
// Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls.
Expand Down Expand Up @@ -1483,13 +1516,51 @@ export function AddProjectDestinationScreen(props: {
) : null}
{environment ? (
<>
<ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} />
<PrimaryActionButton
label="Clone project"
disabled={!canCloneProject || isBrowseNavigating || isSubmitting || !remoteUrl}
onPress={submitPath}
loading={isSubmitting}
<ProjectPathInput
value={pathInput}
onChangeText={setPathInput}
onSubmit={() => submitPath()}
/>
{hasConflict ? (
<View className="gap-3 rounded-[24px] bg-grouped-card p-4">
<Text className="text-base font-t3-bold">This folder already contains files.</Text>
<Text className="text-sm text-foreground-muted">
Combine keeps existing files, including conflicts, and adds missing files. An
existing checkout keeps its branch and local changes. Replace removes the folder's
contents after the new clone succeeds.
</Text>
<Button
label="Combine"
fullWidth
tone="primary"
disabled={!canCloneProject || isBrowseNavigating || isSubmitting}
onPress={() => submitPath("combine")}
/>
<Button
label="Replace"
fullWidth
tone="danger"
disabled={!canCloneProject || isBrowseNavigating || isSubmitting}
onPress={() => submitPath("replace")}
/>
<Button
label="Cancel"
fullWidth
disabled={isSubmitting}
onPress={() => {
setConflict(null);
setError(null);
}}
/>
</View>
) : (
<PrimaryActionButton
label="Clone project"
disabled={!canCloneProject || isBrowseNavigating || isSubmitting || !remoteUrl}
onPress={() => submitPath()}
loading={isSubmitting}
/>
)}
{!canCloneProject ? (
<Text className="text-sm text-foreground-muted">
This connection cannot clone projects.
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ export const make = Effect.gen(function* () {
...(hostPlatform === "win32" ? {} : { localServerStop: true }),
environmentIcon: true,
projectCloneTracking: true,
projectCloneExistingDestination: true,
projectOrganization: true,
ignoredFileListing: true,
triage: true,
Expand Down
Loading
Loading