Files
kandev/apps/web/components/task/task-pr-picker-dialog.tsx
T
2026-07-21 23:24:22 +01:00

126 lines
4.9 KiB
TypeScript

"use client";
import { useRef, type KeyboardEvent } from "react";
import { IconGitPullRequest } from "@tabler/icons-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { cn } from "@/lib/utils";
import { getPRStatusColor } from "@/components/github/pr-task-icon";
import { openExternalLink } from "@/lib/desktop/external-links";
import type { TaskPR } from "@/lib/types/github";
import type { TaskMR } from "@/lib/types/gitlab";
type TaskPRPickerDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
prs: TaskPR[];
mrs?: TaskMR[];
};
/**
* Picker shown by the open-task-PR shortcut when a task has several linked
* PRs. A scrollable list of rows; ArrowUp/ArrowDown move focus (wrapping),
* Enter or click opens the focused PR on GitHub and closes the dialog.
*/
export function TaskPRPickerDialog({ open, onOpenChange, prs, mrs = [] }: TaskPRPickerDialogProps) {
const listRef = useRef<HTMLDivElement>(null);
const openPR = (pr: TaskPR) => {
void openExternalLink(pr.pr_url).catch(() => undefined);
onOpenChange(false);
};
const openMR = (mr: TaskMR) => {
void openExternalLink(mr.mr_url).catch(() => undefined);
onOpenChange(false);
};
const onListKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const rows = Array.from(
listRef.current?.querySelectorAll<HTMLButtonElement>("button[data-pr-row]") ?? [],
);
if (rows.length === 0) return;
const idx = rows.findIndex((row) => row === document.activeElement);
const delta = e.key === "ArrowDown" ? 1 : -1;
if (idx === -1) {
rows[delta === 1 ? 0 : rows.length - 1].focus();
return;
}
rows[(idx + delta + rows.length) % rows.length].focus();
};
// Focus the first PR row explicitly on open instead of relying on Radix's
// implicit first-focusable ordering, so keyboard flow (ArrowDown/Enter)
// stays stable if the dialog template's DOM order changes.
const focusFirstRow = (e: Event) => {
e.preventDefault();
listRef.current?.querySelector<HTMLButtonElement>("button[data-pr-row]")?.focus();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg" onOpenAutoFocus={focusFirstRow}>
<DialogHeader>
<DialogTitle>{mrs.length ? "Open code review" : "Open pull request"}</DialogTitle>
<DialogDescription>
{mrs.length
? "Choose a linked pull request or merge request to open at its provider."
: `This task has ${prs.length} linked pull requests. Choose one to open on GitHub.`}
</DialogDescription>
</DialogHeader>
<div
ref={listRef}
data-testid="task-pr-picker-list"
className="-mx-2 flex max-h-[50vh] flex-col gap-1 overflow-y-auto px-2"
onKeyDown={onListKeyDown}
>
{prs.map((pr) => (
<button
key={pr.id}
type="button"
data-pr-row
data-testid={`task-pr-picker-row-${pr.id}`}
onClick={() => openPR(pr)}
className="flex cursor-pointer items-center gap-2 rounded-md border border-transparent px-2 py-2 text-left text-sm transition-colors hover:border-border hover:bg-accent/40 focus:border-primary/70 focus:outline-none"
>
<IconGitPullRequest className={cn("h-4 w-4 shrink-0", getPRStatusColor(pr))} />
<span className="min-w-0 flex-1 truncate">
<span className="font-medium">
{pr.repo} #{pr.pr_number}
</span>{" "}
<span className="text-muted-foreground">{pr.pr_title}</span>
</span>
<span className="shrink-0 text-xs capitalize text-muted-foreground">{pr.state}</span>
</button>
))}
{mrs.map((mr) => (
<button
key={mr.id}
type="button"
data-pr-row
data-testid={`task-mr-picker-row-${mr.id}`}
onClick={() => openMR(mr)}
className="flex min-h-11 cursor-pointer items-center gap-2 rounded-md border border-transparent px-2 py-2 text-left text-sm transition-colors hover:border-border hover:bg-accent/40 focus:border-primary/70 focus:outline-none"
>
<IconGitPullRequest className="h-4 w-4 shrink-0 text-orange-500" />
<span className="min-w-0 flex-1 truncate">
<span className="font-medium">
{mr.project_path} !{mr.mr_iid}
</span>{" "}
<span className="text-muted-foreground">{mr.mr_title}</span>
</span>
<span className="shrink-0 text-xs capitalize text-muted-foreground">{mr.state}</span>
</button>
))}
</div>
</DialogContent>
</Dialog>
);
}