import { useParams } from "@tanstack/react-router"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { getAddProjectInitialQuery, getCloneDestinationPath, getCloneDirectoryPath, } from "@t3tools/client-runtime/operations/projects"; import { resolvePastedPath } from "@t3tools/shared/path"; import { type EnvironmentId, type ProjectIgnoredFile, type ScopedProjectRef, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { type CloneProtocol, cloneUrlForProtocol, cloneUrlProtocol, } from "@t3tools/shared/cloneUrl"; import { ArrowRightIcon } from "lucide-react"; import { useId, useState } from "react"; import { create } from "zustand"; import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; import { useAddProjectTargets } from "../hooks/useAddProjectTargets"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; import { COPYABLE_FILE_MAX_BYTES, addProjectToMachine, isDefaultCopiedFile, } from "../lib/addProjectToMachine"; import { formatBytes } from "../lib/formatBytes"; import { newProjectId } from "../lib/utils"; import { readProject, subscribeProject, useProject } from "../state/entities"; import { useEnvironments } from "../state/environments"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Button } from "./ui/button"; import { Checkbox } from "./ui/checkbox"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; import { MiddleTruncate } from "./ui/middle-truncate"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Toggle, ToggleGroup } from "./ui/toggle-group"; interface AddProjectToMachineRequest { readonly projectRef: ScopedProjectRef; readonly targetEnvironmentId: EnvironmentId | null; } const useRequest = create<{ request: AddProjectToMachineRequest | null; session: number }>(() => ({ request: null, session: 0, })); let endRequestWithProject: (() => void) | null = null; /** Opens the dialog that clones a project onto another connected machine. */ export function openAddProjectToMachine(request: AddProjectToMachineRequest) { if (readProject(request.projectRef) === null) return; endRequestWithProject?.(); // The request ends when its project goes away, so a project that comes back // later cannot reopen a dialog nobody asked for. endRequestWithProject = subscribeProject(request.projectRef, (project) => { if (project === null) close(); }); useRequest.setState((state) => ({ request, session: state.session + 1 })); } function close() { endRequestWithProject?.(); endRequestWithProject = null; useRequest.setState({ request: null }); } export function AddProjectToMachineDialogHost() { const request = useRequest((state) => state.request); const session = useRequest((state) => state.session); return request ? : null; } function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRequest }) { const id = useId(); const project = useProject(request.projectRef); const handleNewThread = useNewThreadHandler(); const { draftId: routeDraftId } = useParams({ strict: false }); const { environments } = useEnvironments(); const targets = useAddProjectTargets(project, useLogicalProjectEnvironments(project, false)); const [targetId, setTargetId] = useState(request.targetEnvironmentId); const target = targets.find((t) => t.environmentId === targetId) ?? targets[0] ?? null; const source = environments.find((env) => env.environmentId === request.projectRef.environmentId); const sourceUrl = project?.repositoryIdentity?.origin?.remoteUrl ?? project?.repositoryIdentity?.locator.remoteUrl ?? ""; const [protocol, setProtocol] = useState(() => cloneUrlProtocol(sourceUrl)); const sshUrl = cloneUrlForProtocol(sourceUrl, "ssh"); const httpsUrl = cloneUrlForProtocol(sourceUrl, "https"); const remoteUrl = (protocol === "ssh" ? sshUrl : protocol === "https" ? httpsUrl : null) ?? sourceUrl; const targetEnvironment = environments.find((env) => env.environmentId === target?.environmentId); const defaultPath = getCloneDestinationPath( getAddProjectInitialQuery(targetEnvironment?.serverConfig?.settings.addProjectBaseDirectory), getCloneDirectoryPath( sourceUrl, targetEnvironment?.serverConfig?.settings.ghqCloneLayout === true, ), ); const [pathOverride, setPathOverride] = useState(null); const destinationPath = pathOverride ?? defaultPath; const canListFiles = source?.serverConfig?.environment.capabilities.ignoredFileListing === true; const ignoredFiles = useEnvironmentQuery( project && canListFiles ? projectEnvironment.listIgnoredFiles({ environmentId: project.environmentId, input: { cwd: project.workspaceRoot }, }) : null, ); const files = ignoredFiles.data?.files ?? []; const [selected, setSelected] = useState | null>(null); const selectedFiles = selected ?? defaultSelection(files); const copyable = files.filter((file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES); const selectedBytes = files .filter((file) => selectedFiles.has(file.path)) .reduce((total, file) => total + file.byteLength, 0); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); // The project or every target can vanish while the dialog is open. Submitting // before the file list loads would silently skip the default .env copies. const unavailable = project === null || target === null || (canListFiles && ignoredFiles.data === null && ignoredFiles.error === null); const toggleFile = (path: string, checked: boolean) => { const next = new Set(selectedFiles); if (checked) next.add(path); else next.delete(path); setSelected(next); }; // Lands the user on the new machine right away; its composer shows the clone // and holds sends until it finishes. A draft open on the source project moves // over with whatever was typed in it. const openClonedProject = (targetRef: ScopedProjectRef) => { const drafts = useComposerDraftStore.getState(); const draftId = routeDraftId as DraftId | undefined; const draft = draftId ? drafts.getDraftSession(draftId) : null; if ( draftId && draft && !draft.promotedTo && draft.environmentId === request.projectRef.environmentId && draft.projectId === request.projectRef.projectId ) { drafts.setDraftThreadContext(draftId, { projectRef: targetRef, environmentSelection: "manual", loadBalancedEnvironmentId: null, }); return; } void handleNewThread(targetRef); }; const submit = async () => { if (!project || !target || submitting) return; if (destinationPath.trim().length === 0) { setError("Choose a folder on the other machine."); return; } setSubmitting(true); setError(null); try { const projectId = await addProjectToMachine({ source: { environmentId: project.environmentId, cwd: project.workspaceRoot }, subdirectory: projectSubdirectory( project.repositoryIdentity?.rootPath, project.workspaceRoot, ), target: { environmentId: target.environmentId, label: target.label }, projectId: newProjectId(), title: project.title, remoteUrl, destinationPath: destinationPath.trim(), files: files.filter((file) => selectedFiles.has(file.path)).map((file) => file.path), }); close(); openClonedProject(scopeProjectRef(target.environmentId, projectId)); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not start the clone."); setSubmitting(false); } }; return ( { if (!open) close(); }} >
{ event.preventDefault(); void submit(); }} > Add to another machine Clone {project?.repositoryIdentity?.displayName ?? project?.title} and bring the local files git does not track.
{source?.label ?? "This machine"}
git clone {selectedFiles.size === 0 ? "no files" : `+ ${selectedFiles.size} ${selectedFiles.size === 1 ? "file" : "files"}`}
{targets.length > 1 ? ( ) : ( {target ? ( ) : null} {target?.label ?? "No machine available"} )}
{sshUrl && httpsUrl ? ( { const value = next[0]; if (value === "ssh" || value === "https") setProtocol(value); }} > SSH HTTPS ) : null} {remoteUrl}
setPathOverride(resolvePastedPath(destinationPath, event.target.value)) } spellCheck={false} autoComplete="off" />
{canListFiles ? (
{copyable.length > 0 ? ( <> {selectedFiles.size} of {files.length} {selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""} ) : null}
{ignoredFiles.error ? (

{ignoredFiles.error}

) : files.length === 0 ? (

{ignoredFiles.isSuccess ? "No ignored files to copy." : "Finding ignored files…"}

) : (
{files.map((file) => ( toggleFile(file.path, checked)} /> ))}
)} {ignoredFiles.data?.truncated ? (

Showing the first {files.length} files.

) : null}

Files are copied after the clone finishes. Folders git ignores whole, like node_modules, are left out.

) : null} {error ?

{error}

: null}
); } /** * A project can be rooted below its repository's top folder. The clone lands * at the top, so copied files keep the same path below it. */ function projectSubdirectory(repositoryRoot: string | undefined, workspaceRoot: string) { if (!repositoryRoot) return ""; const prefix = repositoryRoot.replace(/[\\/]+$/, ""); const rest = workspaceRoot.slice(prefix.length); return workspaceRoot.startsWith(prefix) && /^[\\/]/.test(rest) ? rest.slice(1).replaceAll("\\", "/") : ""; } function defaultSelection(files: readonly ProjectIgnoredFile[]): ReadonlySet { return new Set( files .filter( (file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES && isDefaultCopiedFile(file.path), ) .map((file) => file.path), ); } function IgnoredFileRow({ file, checked, onCheckedChange, }: { file: ProjectIgnoredFile; checked: boolean; onCheckedChange: (checked: boolean) => void; }) { const tooLarge = file.byteLength > COPYABLE_FILE_MAX_BYTES; return ( ); }