Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ type AddProjectDestinationRouteParams = {
readonly repositoryTitle?: string | string[];
readonly repositoryName?: string | string[];
readonly prompt?: string | string[];
readonly draftId?: string | string[];
readonly sourceEnvironmentId?: string | string[];
readonly sourceCwd?: string | string[];
readonly sourceSubdirectory?: string | string[];
};

export function AddProjectDestinationRoute({
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/projects/AddProjectScreen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ vi.mock("../../state/sourceControl", () => ({
},
},
}));
vi.mock("../../connection/catalog", () => ({
environmentCatalog: { sessionGenerationAtom: () => null },
}));
vi.mock("../../state/projectClones", () => ({
setProjectFilesCopying: () => {},
}));
vi.mock("../../state/projects", () => ({
projectEnvironment: {
createNew: () => state.createNewResult,
Expand Down
192 changes: 185 additions & 7 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspace
import * as Dialog from "../../components/ui/Dialog";
import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import { NavigationRow } from "../../components/ui/NavigationRow";
import { SelectionRow } from "../../components/ui/SelectionRow";
import { SettingsScreen } from "../settings/components/SettingsScreen";
import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
import { Button } from "../../components/ui/Button";
Expand Down Expand Up @@ -49,6 +50,12 @@ import {
inferProjectTitleFromPath,
isWindowsPlatform,
} from "@t3tools/client-runtime/state/projects";
import {
COPYABLE_FILE_MAX_BYTES,
defaultCopiedFiles,
readProjectFilesToCopy,
writeProjectFilesAfterClone,
} from "@t3tools/client-runtime/state/project-file-copy";
import { resolvePastedPath } from "@t3tools/shared/path";
import {
AuthOrchestrationOperateScope,
Expand All @@ -58,6 +65,7 @@ import {
type EnvironmentId,
type EnvironmentMachineKind,
ProjectId,
type ProjectIgnoredFile,
resolveEnvironmentMachineKind,
SourceControlProviderKind,
} from "@t3tools/contracts";
Expand All @@ -76,6 +84,10 @@ import * as Cause from "effect/Cause";
import * as Order from "effect/Order";
import { AsyncResult } from "effect/reactivity";
import { cn } from "../../lib/cn";
import { formatBytes } from "../../lib/formatBytes";
import { environmentCatalog } from "../../connection/catalog";
import { appAtomRegistry } from "../../state/atom-registry";
import { setProjectFilesCopying } from "../../state/projectClones";
import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";
import { filesystemEnvironment } from "../../state/filesystem";
import { projectEnvironment } from "../../state/projects";
Expand Down Expand Up @@ -104,9 +116,11 @@ import {
} from "./AddProjectScreen.logic";
import {
createNewTaskDraft,
retargetNewTaskDraft,
setComposerDraftText,
waitForComposerDraftsLoaded,
} from "../../state/use-composer-drafts";
import { isNewTaskDraftKey } from "../../state/new-task-draft-key";

interface EnvironmentOption {
readonly environmentId: EnvironmentId;
Expand Down Expand Up @@ -724,6 +738,8 @@ function useOpenNewTaskDraft() {
draftId?: string;
},
originRoute: NavigationState<ParamListBase>["routes"][number] | undefined,
/** Restamp `draftId` to this project first; skipped with the navigation it belongs to. */
moveDraft = false,
) => {
const activeRoute = getActiveRoute(navigation);
// A warm link can change the task without replacing its native route key.
Expand All @@ -734,6 +750,7 @@ function useOpenNewTaskDraft() {
activeRoute.params !== originRoute.params
)
return;
if (moveDraft && params.draftId) retargetNewTaskDraft(params.draftId, params);
selectDetail({ name: "Thread", params: { draft: params } });
};
}
Expand Down Expand Up @@ -1427,12 +1444,90 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st
);
}

/** Collapsed to its summary so a long list does not push the folder browser off screen. */
function IgnoredFilesToCopy(props: {
readonly files: ReadonlyArray<ProjectIgnoredFile>;
readonly selected: ReadonlyMap<string, ProjectIgnoredFile>;
readonly onSelect: (selected: ReadonlyMap<string, ProjectIgnoredFile>) => void;
/** Shown in place of the count: loading, empty, or the listing error. */
readonly status: string | null;
readonly truncated: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const selectedBytes = [...props.selected.values()].reduce(
(total, file) => total + file.byteLength,
0,
);
return (
<>
<ListSection>
<ListRow
isFirst
title="Copy ignored files"
subtitle={
props.status ??
`${props.selected.size} of ${props.files.length} selected${
selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""
}`
}
icon={<SymbolView name="doc.on.doc" size={18} tintColorClassName="accent-icon-muted" />}
disabled={props.files.length === 0}
right={
props.files.length > 0 ? (
<SymbolView
name={expanded ? "chevron.up" : "chevron.down"}
size={13}
tintColorClassName="accent-chevron"
/>
) : null
}
onPress={() => setExpanded(!expanded)}
/>
{expanded
? props.files.map((file, index) => {
const tooLarge = file.byteLength > COPYABLE_FILE_MAX_BYTES;
return (
<SelectionRow
key={file.path}
role="checkbox"
title={file.path}
subtitle={tooLarge ? "Over 1 MB" : formatBytes(file.byteLength)}
selected={props.selected.has(file.path)}
disabled={tooLarge}
isLast={index === props.files.length - 1}
onPress={() => {
const next = new Map(props.selected);
if (!next.delete(file.path)) next.set(file.path, file);
props.onSelect(next);
}}
/>
);
})
: null}
</ListSection>
{props.files.length > 0 ? (
<Text className="px-1 text-xs text-foreground-muted">
{props.truncated ? `Showing the first ${props.files.length} files. ` : ""}
Files are copied after the clone finishes. Folders git ignores whole, like node_modules,
are left out.
</Text>
) : null}
</>
);
}

export function AddProjectDestinationScreen(props: {
readonly environmentId?: string | string[];
readonly remoteUrl?: string | string[];
readonly repositoryTitle?: string | string[];
readonly repositoryName?: string | string[];
readonly prompt?: string | string[];
/** A new-task draft to carry onto the clone, when a project is added to another machine. */
readonly draftId?: string | string[];
/** The project being added to another machine; its ignored files can come along. */
readonly sourceEnvironmentId?: string | string[];
readonly sourceCwd?: string | string[];
readonly sourceSubdirectory?: string | string[];
}) {
const openNewTaskDraft = useOpenNewTaskDraft();
const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
Expand All @@ -1456,6 +1551,8 @@ export function AddProjectDestinationScreen(props: {
const remoteUrl = stringParam(props.remoteUrl);
const repositoryTitle = stringParam(props.repositoryTitle);
const prompt = stringParam(props.prompt);
const draftIdParam = stringParam(props.draftId);
const movedDraftId = draftIdParam && isNewTaskDraftKey(draftIdParam) ? draftIdParam : null;
const repositoryName = stringParam(props.repositoryName)?.trim() ?? "";
const ghqDirectoryPath =
remoteUrl && environment?.ghqCloneLayout
Expand All @@ -1468,13 +1565,43 @@ export function AddProjectDestinationScreen(props: {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

const sourceEnvironmentId = stringParam(props.sourceEnvironmentId) as EnvironmentId | null;
const sourceCwd = stringParam(props.sourceCwd);
const serverConfigs = useServerConfigs();
const canListFiles =
sourceEnvironmentId !== null &&
sourceCwd !== null &&
serverConfigs.get(sourceEnvironmentId)?.environment.capabilities.ignoredFileListing === true;
const ignoredFiles = useEnvironmentQuery(
canListFiles
? projectEnvironment.listIgnoredFiles({
environmentId: sourceEnvironmentId,
input: { cwd: sourceCwd },
})
: null,
);
const files = ignoredFiles.data?.files ?? [];
const [fileSelection, setFileSelection] = useState<ReadonlyMap<
string,
ProjectIgnoredFile
> | null>(null);
const selectedFiles = fileSelection ?? defaultCopiedFiles(files);
// Submitting before the list loads would silently skip the default .env copies.
const filesLoading = canListFiles && ignoredFiles.data === null && ignoredFiles.error === null;
let ignoredFilesStatus = ignoredFiles.error;
if (filesLoading) ignoredFilesStatus = "Finding ignored files…";
else if (ignoredFilesStatus === null && files.length === 0) {
ignoredFilesStatus = "No ignored files to copy.";
}

const submitPath = async () => {
if (
!environment ||
!readEnvironmentScope(environment.environmentId, AuthSourceControlWriteScope) ||
!readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope) ||
!remoteUrl ||
isBrowseNavigating ||
filesLoading ||
isSubmitting
) {
return;
Expand Down Expand Up @@ -1502,6 +1629,15 @@ export function AddProjectDestinationScreen(props: {
return;
}
const title = inferProjectTitleFromPath(resolved.path);
const reads =
canListFiles && selectedFiles.size > 0
? await readProjectFilesToCopy(
appAtomRegistry,
projectEnvironment,
{ environmentId: sourceEnvironmentId, cwd: sourceCwd },
[...selectedFiles.keys()],
)
: [];
const startResult = await startProjectClone({
environmentId: environment.environmentId,
input: {
Expand All @@ -1517,6 +1653,31 @@ export function AddProjectDestinationScreen(props: {
} else {
void suggestAppAccessForRemote(checkAppAccess, environment.environmentId, remoteUrl);
const { projectId } = startResult.value;
if (reads.length > 0) {
const cloneRef = { environmentId: environment.environmentId, projectId };
setProjectFilesCopying(cloneRef, true);
void writeProjectFilesAfterClone(appAtomRegistry, projectEnvironment, {
clonesAtom: sourceControlEnvironment.projectClones({
environmentId: environment.environmentId,
input: {},
}),
sessionGenerationAtom: environmentCatalog.sessionGenerationAtom(
environment.environmentId,
),
...cloneRef,
cwd: startResult.value.cwd,
subdirectory: stringParam(props.sourceSubdirectory) ?? "",
reads,
}).then((failed) => {
setProjectFilesCopying(cloneRef, false);
if (failed.length > 0) {
Dialog.alert(
`Could not copy ${failed.length} ${failed.length === 1 ? "file" : "files"} to ${environment.label}`,
failed.join("\n"),
);
}
});
}
// Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls.
const project = await waitForProject(
{ environmentId: environment.environmentId, projectId },
Expand All @@ -1527,20 +1688,26 @@ export function AddProjectDestinationScreen(props: {
"The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.",
);
} else {
let draftId: string | undefined;
if (movedDraftId) {
// A failed hydration is already logged; still open the new project.
await waitForComposerDraftsLoaded().catch(() => undefined);
draftId = movedDraftId;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} else if (prompt) {
draftId = await draftWithPrompt(environment.environmentId, projectId, prompt).catch(
() => undefined,
);
}
openNewTaskDraft(
{
environmentId: environment.environmentId,
projectId,
title,
cloning: "1",
...(prompt
? await draftWithPrompt(environment.environmentId, projectId, prompt).then(
(draftId) => ({ draftId }),
() => ({}),
)
: {}),
...(draftId ? { draftId } : {}),
},
originRoute,
movedDraftId !== null,
);
}
}
Expand All @@ -1563,7 +1730,9 @@ export function AddProjectDestinationScreen(props: {
<ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} />
<PrimaryActionButton
label="Clone project"
disabled={!canCloneProject || isBrowseNavigating || isSubmitting || !remoteUrl}
disabled={
!canCloneProject || isBrowseNavigating || filesLoading || isSubmitting || !remoteUrl
}
onPress={submitPath}
loading={isSubmitting}
/>
Expand All @@ -1572,6 +1741,15 @@ export function AddProjectDestinationScreen(props: {
This connection cannot clone projects.
</Text>
) : null}
{canListFiles ? (
<IgnoredFilesToCopy
files={files}
selected={selectedFiles}
onSelect={setFileSelection}
status={ignoredFilesStatus}
truncated={ignoredFiles.data?.truncated === true}
/>
) : null}
<FolderBrowser
environment={environment}
navigateToBrowsePath={navigateToBrowsePath}
Expand Down
Loading
Loading