Files
kandev/apps/web/components/task-create-dialog-remote-repo-chip.tsx

664 lines
23 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
RepositoryOptions,
RepositoryOptionsSummary,
} from "./task-create-dialog-repository-options";
import type { RepositoryCheckoutOptions } from "@/lib/types/repository-checkout-options";
import Link from "@/components/routing/app-link";
import { IconCheck, IconGitBranch, IconLink, IconX } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import type { Branch } from "@/lib/types/http";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Spinner } from "@kandev/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { Pill } from "@/components/task-create-dialog-pill";
import {
branchToOption,
sortBranches,
computeBranchPlaceholder,
} from "@/components/branch-picker-options";
import { scoreBranch } from "@/lib/utils/branch-filter";
import type {
RemoteRepository,
RemoteRepositoryProvider,
UseRemoteRepositoriesResult,
} from "@/hooks/domains/integrations/use-remote-repositories";
import { parseGitHubAnyUrl, type PRInfo } from "@/hooks/domains/github/use-pr-info-by-url";
import type { TaskRemoteRepoRow } from "@/components/task-create-dialog-types";
import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container";
import {
RemoteRepositoryProviderIcon,
RemoteRepoProviderTabs,
} from "@/components/task-create-dialog-remote-repo-provider-tabs";
import { remoteRepositoryMatchesSelection } from "./task-create-dialog-remote-repo-identity";
export { selectedRemoteRepositoryIdentity } from "./task-create-dialog-remote-repo-identity";
import {
looksLikeURL,
looksLikeSupportedRemoteURL,
} from "@/components/workspace-source-picker/remote-url";
import { Trans, useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
const TRUNCATE_THRESHOLD = 30;
export type RemoteRepoChipProps = {
workspaceId?: string | null;
executorProfileId?: string;
onOptionsChange?: (options?: RepositoryCheckoutOptions) => void;
row: TaskRemoteRepoRow;
branches: Branch[];
branchesLoading: boolean;
prInfo?: PRInfo;
resolutionError?: Error;
accessibleRepos: UseRemoteRepositoriesResult;
/** Identities selected by other rows. Matching entries remain selectable. */
selectedRepositoryIdentities?: string[];
onURLChange: (
url: string,
source: "picker" | "paste",
metadata?: {
provider: RemoteRepositoryProvider;
fullName: string;
defaultBranch: string;
remoteUrl?: string;
providerHost?: string;
providerScope?: string;
providerRepoId?: string;
providerOwner?: string;
providerName?: string;
},
) => void;
onBranchChange: (branch: string) => void;
onRetry?: () => void;
onRemove: () => void;
};
/**
* Single chip in the Remote tab. Layout mirrors `RepoChip`:
*
* [ repo pill ] [ branch pill ] [X]
*
* The repo pill opens a custom popover with one input that searches the
* user's accessible GitHub repos or accepts a pasted GitHub URL.
* The branch pill is the shared `Pill` primitive over the per-URL branches
* the parent loads via `branchesByUrl`.
*/
export function RemoteRepoChip({
workspaceId,
executorProfileId,
onOptionsChange,
row,
branches,
branchesLoading,
prInfo,
resolutionError,
accessibleRepos,
selectedRepositoryIdentities = [],
onURLChange,
onBranchChange,
onRetry,
onRemove,
}: RemoteRepoChipProps) {
useRowBranchAutoSelect({ row, branches, prInfo, onBranchChange });
return (
<div
className="flex min-w-0 max-w-full flex-col items-start gap-1"
data-testid="remote-repo-chip-wrapper"
>
<span
className="grid max-w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center rounded-md border border-input bg-input/20 dark:bg-input/30 pr-0.5 md:inline-flex"
data-testid="remote-repo-chip"
data-remote-url={row.url}
>
<RemoteRepoPill
row={row}
accessibleRepos={accessibleRepos}
selectedRepositoryIdentities={selectedRepositoryIdentities}
onURLChange={onURLChange}
/>
<RemoteBranchPill
url={row.url}
branch={row.branch}
branches={branches}
branchesLoading={branchesLoading}
onBranchChange={onBranchChange}
/>
{onOptionsChange && row.url.trim() && (
<RepositoryOptions
key={row.url}
row={row}
workspaceId={workspaceId}
executorProfileId={executorProfileId}
onChange={onOptionsChange}
/>
)}
<RemoveButton onRemove={onRemove} />
</span>
<RepositoryOptionsSummary options={row.checkoutOptions} />
{resolutionError && onRetry ? (
<RemoteResolutionError error={resolutionError} onRetry={onRetry} />
) : null}
</div>
);
}
function RemoteResolutionError({ error, onRetry }: { error: Error; onRetry: () => void }) {
const { t } = useTranslation();
return (
<span className="flex max-w-full items-center gap-2 text-xs text-destructive" role="alert">
<span className="min-w-0 break-words">
{t("task:couldNotResolveRemoteRepository", { message: error.message })}
</span>
<Button
type="button"
variant="outline"
className="cursor-pointer"
aria-label={t("task:retryRemoteRepositoryResolution")}
onClick={onRetry}
>
{t("task:retry")}
</Button>
</span>
);
}
/**
* Per-row branch autoselect for the Remote tab. Runs whenever the row's
* URL / PR-info / branch list changes.
*
* Order of preference when the auto-selector is allowed to write:
* 1. PR head branch (when the row's URL is a PR URL and PR info has
* loaded). Wins regardless of whether the head appears in the base
* repo's branch list — fork PRs keep the head name surfaced on the
* pill even though `origin` can't resolve it.
* 2. `main` / `master` / first available, from the per-URL branch list.
*
* The PR head must outrank a list-derived default even when the branch LIST
* resolves before the PR info: if the list resolves first and the auto-selector
* writes `main`, the later-arriving PR head must still replace it. A naive
* `if (row.branch) return` guard breaks this — it bails once `main` is set.
*
* To distinguish "the auto-selector set this" from "the user picked this", we
* track the last value the auto-selector wrote in `autoSetRef`. The auto-
* selector may overwrite `row.branch` only when it is empty OR equals the last
* value we wrote; a value that differs from the ref means the user picked it,
* and we never clobber a user pick. When the row's URL is empty the effect is a
* no-op.
*/
function useRowBranchAutoSelect(args: {
row: TaskRemoteRepoRow;
branches: Branch[];
prInfo?: PRInfo;
onBranchChange: (branch: string) => void;
}) {
const { row, branches, prInfo, onBranchChange } = args;
// Last branch value this auto-selector wrote. Used to tell an auto-set value
// (safe to overwrite) apart from a user pick (must be preserved).
const autoSetRef = useRef<string | null>(null);
// The URL the autoSetRef belongs to. When the row switches to a different
// repo/URL, ownership resets — otherwise a branch prefilled for the new URL
// (e.g. its default_branch) could be mistaken for an auto-set value and
// clobbered, or a stale value could leak across repos.
const lastUrlRef = useRef<string>("");
useEffect(() => {
if (!row.url) return;
if (row.url !== lastUrlRef.current) {
lastUrlRef.current = row.url;
autoSetRef.current = null;
}
// A non-empty branch that we didn't write ourselves is a user pick — leave
// it alone.
if (row.branch && row.branch !== autoSetRef.current) return;
const desired = computeAutoSelectedBranch(prInfo, branches);
if (!desired) return;
if (desired === row.branch) {
// Already on the desired value (e.g. we wrote it on a prior run); just
// make sure the ref reflects it so a later user pick is detectable.
autoSetRef.current = desired;
return;
}
autoSetRef.current = desired;
onBranchChange(desired);
}, [row.url, row.branch, prInfo, branches, onBranchChange]);
}
// computeAutoSelectedBranch returns the branch the auto-selector wants for a
// row: the PR head branch (when known) outranks a list-derived default
// (main → master → first available). Returns "" when nothing can be chosen yet.
function computeAutoSelectedBranch(prInfo: PRInfo | undefined, branches: Branch[]): string {
if (prInfo?.prHeadBranch) return prInfo.prHeadBranch;
if (branches.length === 0) return "";
const preferred =
branches.find((b) => b.name === "main") ??
branches.find((b) => b.name === "master") ??
branches[0];
return preferred?.name ?? "";
}
// --- Repo pill ---------------------------------------------------------------
function RemoteRepoPill({
row,
accessibleRepos,
selectedRepositoryIdentities,
onURLChange,
}: {
row: TaskRemoteRepoRow;
accessibleRepos: UseRemoteRepositoriesResult;
selectedRepositoryIdentities: string[];
onURLChange: RemoteRepoChipProps["onURLChange"];
}) {
const [open, setOpen] = useState(false);
const portalContainer = useTaskCreateDialogPopoverContainer();
const triggerLabel = useMemo(() => computeTriggerLabel(row), [row]);
const hasValue = !!row.url;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-testid="remote-repo-chip-trigger"
className={cn(
"col-span-3 h-7 min-w-0 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs bg-transparent md:col-span-1 [@media(pointer:coarse)]:h-11",
"hover:bg-muted/60 cursor-pointer",
!hasValue && "text-muted-foreground",
)}
>
<RepoTriggerIcon row={row} />
<span className="truncate max-w-[240px]">{triggerLabel}</span>
</button>
</PopoverTrigger>
<PopoverContent
data-testid="remote-repo-popover-content"
className="w-[380px] max-w-[calc(100vw-2rem)] max-h-[min(420px,calc(100vh-12rem))] overflow-hidden p-0"
align="start"
portalContainer={portalContainer}
>
<RemoteRepoPopoverContent
accessible={accessibleRepos}
selectedRepositoryIdentities={selectedRepositoryIdentities}
onPick={(repo) => {
onURLChange(repo.url, "picker", {
provider: repo.provider,
fullName: repo.fullName,
defaultBranch: repo.defaultBranch,
...(repo.provider === "github" ? {} : { remoteUrl: repo.url }),
...(repo.providerHost ? { providerHost: repo.providerHost } : {}),
...(repo.providerScope ? { providerScope: repo.providerScope } : {}),
providerRepoId: repo.id,
providerOwner: repo.owner,
providerName: repo.name,
});
setOpen(false);
}}
onPaste={(value) => {
onURLChange(value, "paste");
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
}
function RepoTriggerIcon({ row }: { row: TaskRemoteRepoRow }) {
if (row.source === "picker" && row.provider) {
return <RemoteRepositoryProviderIcon provider={row.provider} />;
}
return <IconLink className="h-3 w-3 shrink-0 text-muted-foreground" />;
}
export function computeTriggerLabel(row: TaskRemoteRepoRow): string {
if (!row.url) return t("task:pickOrPasteARepo");
if (row.source === "picker" && row.fullName) return row.fullName;
return truncateMiddle(stripScheme(row.url), TRUNCATE_THRESHOLD);
}
function stripScheme(url: string): string {
return url.replace(/^https?:\/\//, "").replace(/^www\./, "");
}
function truncateMiddle(value: string, max: number): string {
if (value.length <= max) return value;
const keep = Math.max(1, Math.floor((max - 1) / 2));
return `${value.slice(0, keep)}…${value.slice(value.length - keep)}`;
}
// --- Popover content ---------------------------------------------------------
function StagedRemoteUrlHint() {
return (
<div className="px-2 pt-1 text-xs text-muted-foreground">
<Trans i18nKey="task:remoteUrlPressEnter">
<span className="font-medium text-foreground">Remote URL</span> - press Enter to submit it.
</Trans>
</div>
);
}
function RemoteRepoPopoverContent({
accessible,
selectedRepositoryIdentities,
onPick,
onPaste,
}: {
accessible: UseRemoteRepositoriesResult;
selectedRepositoryIdentities: string[];
onPick: (repo: RemoteRepository) => void;
onPaste: (value: string) => void;
}) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [urlError, setUrlError] = useState<string | null>(null);
const [activeProvider, setActiveProvider] = useState<RemoteRepositoryProvider | null>(null);
const { search: triggerSearch } = accessible;
const matchesURL = accessible.matchesURL ?? looksLikeSupportedRemoteURL;
useEffect(() => {
triggerSearch(value);
}, [value, triggerSearch]);
const commitURL = (candidate: string) => {
const trimmed = candidate.trim();
if (!isSupportedRemoteURL(trimmed, matchesURL)) {
if (looksLikeURL(trimmed)) {
setUrlError(t("task:enterRepositoryUrl"));
}
return false;
}
setUrlError(null);
onPaste(trimmed);
return true;
};
const visibleUrlError = accessible.unavailable ? null : urlError;
const hasStagedURL = isSupportedRemoteURL(value.trim(), matchesURL);
const { showProviderTabs, selectedProvider, visibleRepos } = visibleProviderRepositories(
accessible,
activeProvider,
);
return (
<div className="flex flex-col">
<input
autoFocus
value={value}
onChange={(event) => {
setValue(event.target.value);
setUrlError(null);
}}
onPaste={(event) => {
const pasted = event.clipboardData.getData("text");
event.preventDefault();
setValue(pasted);
}}
onKeyDown={(event) => {
if (
event.key !== "Enter" ||
event.defaultPrevented ||
event.repeat ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.nativeEvent.isComposing ||
event.keyCode === 229
)
return;
const isURL = looksLikeURL(value.trim());
if (commitURL(value) || isURL) event.preventDefault();
}}
placeholder={t("task:searchRepositoriesOrPasteARemote")}
aria-label={t("task:searchRepositoriesOrPasteARemote")}
aria-invalid={visibleUrlError ? true : undefined}
data-testid="remote-repo-input"
data-legacy-testid="remote-paste-url-input"
className={cn(
"h-11 sm:h-9 mx-2 mt-2 rounded-md px-2 text-xs bg-muted/30 border border-border/60",
"outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground",
visibleUrlError && "border-destructive focus:border-destructive",
)}
/>
{hasStagedURL ? <StagedRemoteUrlHint /> : null}
<PickerList
accessible={{ ...accessible, repos: visibleRepos }}
selectedRepositoryIdentities={selectedRepositoryIdentities}
onPick={onPick}
urlError={visibleUrlError}
/>
{showProviderTabs && selectedProvider ? (
<RemoteRepoProviderTabs
providers={accessible.availableProviders}
value={selectedProvider}
onChange={setActiveProvider}
/>
) : null}
</div>
);
}
function visibleProviderRepositories(
accessible: UseRemoteRepositoriesResult,
activeProvider: RemoteRepositoryProvider | null,
) {
const showProviderTabs = accessible.availableProviders.length > 1;
const selectedProvider =
activeProvider && accessible.availableProviders.includes(activeProvider)
? activeProvider
: accessible.availableProviders[0];
const visibleRepos = showProviderTabs
? accessible.repos.filter((repo) => repo.provider === selectedProvider)
: accessible.repos;
return { showProviderTabs, selectedProvider, visibleRepos };
}
function isSupportedRemoteURL(value: string, matchesURL: (url: string) => boolean): boolean {
return !!parseGitHubAnyUrl(value) || matchesURL(value);
}
function PickerList({
accessible,
selectedRepositoryIdentities,
onPick,
urlError,
}: {
accessible: UseRemoteRepositoriesResult;
selectedRepositoryIdentities: string[];
onPick: (repo: RemoteRepository) => void;
urlError: string | null;
}) {
const { t } = useTranslation();
const { repos, loading, error } = accessible;
return (
<div className="h-56 max-h-[calc(100vh-16rem)] overflow-y-auto p-1">
{urlError ? (
<div role="alert" className="px-2 py-3 text-xs text-destructive">
{urlError}
</div>
) : null}
{accessible.unavailable ? <ConnectProvidersBanner /> : null}
{!accessible.unavailable && loading && repos.length === 0 ? (
<div
className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground"
data-testid="remote-repo-picker-loading"
>
<Spinner className="size-3" />
<span>{t("task:loadingRepositories")}</span>
</div>
) : null}
{!accessible.unavailable && !loading && repos.length === 0 && !error ? (
<div className="px-2 py-3 text-xs text-muted-foreground">
{t("task:noRepositoriesFound")}
</div>
) : null}
{error ? (
<div className="px-2 py-3 text-xs text-destructive">
{t("task:couldNotLoadRepositories", { message: error.message })}
</div>
) : null}
{repos.map((repo) => (
<RepoOption
key={`${repo.provider}:${repo.id}`}
repo={repo}
alreadyAdded={selectedRepositoryIdentities.some((identity) =>
remoteRepositoryMatchesSelection(repo, identity),
)}
onPick={onPick}
/>
))}
</div>
);
}
function RepoOption({
repo,
alreadyAdded,
onPick,
}: {
repo: RemoteRepository;
alreadyAdded: boolean;
onPick: (repo: RemoteRepository) => void;
}) {
const { t } = useTranslation();
return (
<button
type="button"
onClick={() => onPick(repo)}
data-testid="remote-repo-option"
className={cn(
"flex min-h-11 sm:min-h-8 w-full items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-xs",
"hover:bg-muted cursor-pointer text-left",
)}
>
<span className="flex min-w-0 items-center gap-2">
<RemoteRepositoryProviderIcon provider={repo.provider} />
<span className="truncate">{repo.fullName}</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{alreadyAdded ? <AlreadyAddedMarker /> : null}
{repo.private ? (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
{t("task:private")}
</Badge>
) : null}
</span>
</button>
);
}
function AlreadyAddedMarker() {
const { t } = useTranslation();
return (
<span
role="img"
aria-label={t("task:alreadyAdded")}
data-testid="already-added-repository-marker"
className="text-primary"
>
<IconCheck aria-hidden="true" className="h-4 w-4" />
</span>
);
}
function ConnectProvidersBanner() {
return (
<div className="px-3 py-3 text-xs text-muted-foreground">
<Trans i18nKey="task:connectProviderBanner">
Connect a source control provider in{" "}
<Link
href="/settings/integrations"
className="text-foreground underline underline-offset-2 cursor-pointer"
>
Settings
</Link>{" "}
to pick from your repositories.
</Trans>
</div>
);
}
// --- Branch pill -------------------------------------------------------------
function RemoteBranchPill({
url,
branch,
branches,
branchesLoading,
onBranchChange,
}: {
url: string;
branch: string;
branches: Branch[];
branchesLoading: boolean;
onBranchChange: (branch: string) => void;
}) {
const { t } = useTranslation();
const hasUrl = !!url.trim();
const hasBranch = !!branch.trim();
const branchOptions = useMemo(() => sortBranches(branches).map(branchToOption), [branches]);
const placeholder = computeBranchPlaceholder(hasUrl, branchesLoading, branchOptions.length);
// If the row already has a branch (e.g. pre-filled with the repo's
// default_branch from a picker selection), keep the pill enabled so the
// user sees the value as the active selection and can still re-open the
// dropdown to swap branches once the list loads. The pill's own popover
// will show "loading" / "no branches" if the list isn't ready yet.
const disabled = !hasUrl || (!hasBranch && (branchesLoading || branchOptions.length === 0));
return (
<Pill
icon={<IconGitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />}
value={branch}
placeholder={placeholder}
options={branchOptions}
onSelect={onBranchChange}
disabled={disabled}
disabledReason={computeRemoteBranchDisabledReason(
hasUrl,
hasBranch,
branchesLoading,
branchOptions.length,
)}
searchPlaceholder={t("task:searchBranches")}
emptyMessage={branchesLoading ? t("task:loadingBranches") : t("task:noBranches")}
testId="remote-branch-chip-trigger"
filter={scoreBranch}
tooltip={t("task:baseBranch")}
flat
/>
);
}
function computeRemoteBranchDisabledReason(
hasUrl: boolean,
hasBranch: boolean,
branchesLoading: boolean,
optionCount: number,
): string | undefined {
if (!hasUrl) return t("task:selectOrEnterRemoteRepoFirst");
// If a branch is already set the pill is enabled; no disabled reason needed.
if (hasBranch) return undefined;
if (branchesLoading) return t("task:loadingBranches3");
if (optionCount === 0) return t("task:noBranchesForUrl");
return undefined;
}
// --- Remove button -----------------------------------------------------------
function RemoveButton({ onRemove }: { onRemove: () => void }) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onRemove}
aria-label={t("task:removeRepository")}
data-testid="remote-chip-remove"
className="h-6 w-6 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted/60 cursor-pointer [@media(pointer:coarse)]:size-11"
>
<IconX className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent>{t("task:removeRepository")}</TooltipContent>
</Tooltip>
);
}