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

535 lines
18 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { IconBrandGithub, 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 { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Spinner } from "@kandev/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import {
Pill,
branchToOption,
computeBranchPlaceholder,
sortBranches,
} from "@/components/task-create-dialog-pill";
import { scoreBranch } from "@/lib/utils/branch-filter";
import type { UseAccessibleReposResult } from "@/hooks/domains/github/use-accessible-repos";
import type { AccessibleRepo } from "@/lib/api/domains/github-api";
import type { PRInfo } from "@/hooks/domains/github/use-pr-info-by-url";
import type { TaskRemoteRepoRow } from "@/components/task-create-dialog-types";
const TRUNCATE_THRESHOLD = 30;
/**
* Props for the per-row remote-repo chip used in the Remote tab of the
* task-create dialog. The chip itself is presentational — branches and the
* loading flag are passed in by the parent row (which keys them off the
* row's URL via `branchesByUrl`), and writes happen through the supplied
* callbacks.
*
* `onURLChange` receives the new URL plus how it was produced. The "picker"
* arm also carries the canonical `owner/name`, provider, and the repo's
* `default_branch` so the parent can pre-fill the row's branch without
* waiting for the branch list to load; the "paste" arm leaves metadata
* undefined so the row drops any stale picker data and the user picks
* their own branch.
*/
export type RemoteRepoChipProps = {
row: TaskRemoteRepoRow;
branches: Branch[];
branchesLoading: boolean;
/**
* PR info for the row's URL (when the URL is a PR URL and the info has
* loaded). Drives the per-row PR-head auto-select effect: if the row's
* branch is empty, the chip writes the PR head branch into it. The
* dialog separately reads the first row's `suggestedTitle` to autofill
* the task title.
*/
prInfo?: PRInfo;
/**
* Shared `useAccessibleRepos` result hoisted up to the chips-row level so
* one backend request serves every chip in the row (previously each open
* popover fired its own request). Each chip still keeps its own local
* search-text state — the hoisted hook only owns the in-flight fetch and
* the cache. When two popovers happen to be open at once with different
* search texts, both see the same `repos` (the last `search(q)` wins);
* that's acceptable because in practice only one popover is open at a
* time.
*/
accessibleRepos: UseAccessibleReposResult;
onURLChange: (
url: string,
source: "picker" | "paste",
metadata?: {
provider: "github" | "gitlab";
fullName: string;
defaultBranch: string;
},
) => void;
onBranchChange: (branch: string) => 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 two sections (an autocomplete
* search over the user's accessible GitHub repos, and a paste-a-URL input).
* The branch pill is the shared `Pill` primitive over the per-URL branches
* the parent loads via `branchesByUrl`.
*/
export function RemoteRepoChip({
row,
branches,
branchesLoading,
prInfo,
accessibleRepos,
onURLChange,
onBranchChange,
onRemove,
}: RemoteRepoChipProps) {
useRowBranchAutoSelect({ row, branches, prInfo, onBranchChange });
return (
<span
className="inline-flex items-center rounded-md border border-input bg-input/20 dark:bg-input/30 pr-0.5"
data-testid="remote-repo-chip"
data-remote-url={row.url}
>
<RemoteRepoPill row={row} accessibleRepos={accessibleRepos} onURLChange={onURLChange} />
<RemoteBranchPill
url={row.url}
branch={row.branch}
branches={branches}
branchesLoading={branchesLoading}
onBranchChange={onBranchChange}
/>
<RemoveButton onRemove={onRemove} />
</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,
onURLChange,
}: {
row: TaskRemoteRepoRow;
accessibleRepos: UseAccessibleReposResult;
onURLChange: RemoteRepoChipProps["onURLChange"];
}) {
const [open, setOpen] = useState(false);
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(
"h-7 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs bg-transparent",
"hover:bg-muted/60 cursor-pointer",
!hasValue && "text-muted-foreground",
)}
>
<RepoTriggerIcon row={row} />
<span className="truncate max-w-[240px]">{triggerLabel}</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-[380px] p-0" align="start" portal={false}>
<RemoteRepoPopoverContent
accessible={accessibleRepos}
onPick={(repo) => {
onURLChange(`https://github.com/${repo.owner}/${repo.name}`, "picker", {
provider: "github",
fullName: repo.full_name,
defaultBranch: repo.default_branch,
});
setOpen(false);
}}
onPaste={(value) => {
onURLChange(value, "paste");
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
}
function RepoTriggerIcon({ row }: { row: TaskRemoteRepoRow }) {
if (row.source === "picker" && row.provider === "github") {
return <IconBrandGithub className="h-3 w-3 shrink-0 text-muted-foreground" />;
}
return <IconLink className="h-3 w-3 shrink-0 text-muted-foreground" />;
}
export function computeTriggerLabel(row: TaskRemoteRepoRow): string {
if (!row.url) return "Pick or paste a repo";
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 RemoteRepoPopoverContent({
accessible,
onPick,
onPaste,
}: {
accessible: UseAccessibleReposResult;
onPick: (repo: AccessibleRepo) => void;
onPaste: (value: string) => void;
}) {
const [search, setSearch] = useState("");
// Destructure the stable `search` callback so the effect's dep array is
// not invalidated on every render of the parent (the hook's result object
// identity changes each render, but `search` is a useCallback). The hook
// itself owns the 250ms debounce so we just forward the latest value.
const { search: triggerSearch } = accessible;
useEffect(() => {
triggerSearch(search);
}, [search, triggerSearch]);
return (
<div className="flex flex-col">
<PickerSection
accessible={accessible}
search={search}
onSearchChange={setSearch}
onPick={onPick}
/>
<div className="border-t" />
<PasteSection onPaste={onPaste} />
</div>
);
}
function PickerSection({
accessible,
search,
onSearchChange,
onPick,
}: {
accessible: UseAccessibleReposResult;
search: string;
onSearchChange: (v: string) => void;
onPick: (repo: AccessibleRepo) => void;
}) {
if (accessible.unavailable) return <ConnectGitHubBanner />;
return (
<div className="flex flex-col">
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search your GitHub repos…"
data-testid="remote-repo-search"
className={cn(
"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",
)}
/>
<PickerList accessible={accessible} onPick={onPick} />
</div>
);
}
function PickerList({
accessible,
onPick,
}: {
accessible: UseAccessibleReposResult;
onPick: (repo: AccessibleRepo) => void;
}) {
const { repos, loading, error } = accessible;
return (
<div className="max-h-56 overflow-y-auto p-1">
{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>Loading repositories…</span>
</div>
) : null}
{!loading && repos.length === 0 && !error ? (
<div className="px-2 py-3 text-xs text-muted-foreground">No repositories found.</div>
) : null}
{error ? (
<div className="px-2 py-3 text-xs text-destructive">
Could not load repositories: {error.message}
</div>
) : null}
{repos.map((repo) => (
<RepoOption key={repo.full_name} repo={repo} onPick={onPick} />
))}
</div>
);
}
function RepoOption({
repo,
onPick,
}: {
repo: AccessibleRepo;
onPick: (repo: AccessibleRepo) => void;
}) {
return (
<button
type="button"
onClick={() => onPick(repo)}
data-testid="remote-repo-option"
className={cn(
"flex 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="truncate min-w-0">{repo.full_name}</span>
{repo.private ? (
<Badge variant="outline" className="text-[10px] text-muted-foreground shrink-0">
private
</Badge>
) : null}
</button>
);
}
function ConnectGitHubBanner() {
return (
<div className="px-3 py-3 text-xs text-muted-foreground">
Connect a GitHub account in{" "}
<Link
href="/settings/integrations/github"
className="text-foreground underline underline-offset-2 cursor-pointer"
>
Settings
</Link>{" "}
to pick from your repositories.
</div>
);
}
function PasteSection({ onPaste }: { onPaste: (value: string) => void }) {
const [value, setValue] = useState("");
const commit = () => {
const trimmed = value.trim();
if (!trimmed) return;
onPaste(trimmed);
};
return (
<div className="flex flex-col gap-1 p-2">
<label className="text-[11px] text-muted-foreground" htmlFor="remote-paste-url-input">
…or paste a URL/PR
</label>
<input
id="remote-paste-url-input"
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={(e) => {
// If focus moved to another element inside this popover (e.g., a
// picker option click), let that handler run instead of committing
// the paste — otherwise the popover would close before the click
// lands and the user's pick would be lost.
const popoverContent = e.currentTarget.closest('[data-slot="popover-content"]');
if (
popoverContent &&
e.relatedTarget instanceof Node &&
popoverContent.contains(e.relatedTarget)
) {
return;
}
commit();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
}
}}
placeholder="github.com/owner/repo or .../pull/123"
data-testid="remote-paste-url-input"
className={cn(
"h-8 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",
)}
/>
</div>
);
}
// --- Branch pill -------------------------------------------------------------
function RemoteBranchPill({
url,
branch,
branches,
branchesLoading,
onBranchChange,
}: {
url: string;
branch: string;
branches: Branch[];
branchesLoading: boolean;
onBranchChange: (branch: string) => void;
}) {
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="Search branches..."
emptyMessage={branchesLoading ? "Loading branches…" : "No branches"}
testId="remote-branch-chip-trigger"
filter={scoreBranch}
tooltip="Base branch"
flat
/>
);
}
function computeRemoteBranchDisabledReason(
hasUrl: boolean,
hasBranch: boolean,
branchesLoading: boolean,
optionCount: number,
): string | undefined {
if (!hasUrl) return "Enter a GitHub URL first.";
// If a branch is already set the pill is enabled; no disabled reason needed.
if (hasBranch) return undefined;
if (branchesLoading) return "Loading branches…";
if (optionCount === 0) return "No branches available for this URL.";
return undefined;
}
// --- Remove button -----------------------------------------------------------
function RemoveButton({ onRemove }: { onRemove: () => void }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onRemove}
aria-label="Remove repository"
data-testid="remote-chip-remove"
className="h-6 w-6 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted/60 cursor-pointer"
>
<IconX className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent>Remove repository</TooltipContent>
</Tooltip>
);
}