Files
kandev/apps/web/components/gitlab/mr-topbar-button.tsx
T
2026-07-23 14:46:07 +01:00

304 lines
9.2 KiB
TypeScript

"use client";
import { memo, useEffect, useState } from "react";
import Link from "@/components/routing/app-link";
import {
IconBrandGitlab,
IconCheck,
IconClock,
IconExternalLink,
IconGitMerge,
IconPlus,
IconUnlink,
IconX,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import {
useGitLabAvailable,
useTaskMRs,
useWorkspaceMRs,
} from "@/hooks/domains/gitlab/use-task-mr";
import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { deleteTaskMR } from "@/lib/api/domains/gitlab-api";
import type { TaskMR } from "@/lib/types/gitlab";
import type { Repository } from "@/lib/types/http";
import { TaskMRLinkDialog } from "./task-mr-link-dialog";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { mrTaskKey } from "./mr-detail-panel";
/**
* Icon + colour for an MR's combined state. Mirrors github's pr-task-icon
* priority order so a merged MR reads the same as a merged PR: terminal
* states first, then pipeline failures / changes-requested, then ready-to-
* merge, then awaiting-something, then pipeline-running.
*/
function MRStatusIcon({ mr }: { mr: TaskMR }) {
if (mr.state === "merged") return <IconCheck className="h-3 w-3 text-purple-500" />;
if (mr.state === "closed") return <IconX className="h-3 w-3 text-muted-foreground" />;
if (mr.pipeline_state === "failure") return <IconX className="h-3 w-3 text-red-500" />;
if (mr.approval_state === "approved" && mr.pipeline_state === "success" && !mr.draft) {
return <IconCheck className="h-3 w-3 text-emerald-400" />;
}
if (mr.approval_state === "pending") return <IconClock className="h-3 w-3 text-sky-400" />;
if (mr.pipeline_state === "pending") return <IconClock className="h-3 w-3 text-yellow-500" />;
return null;
}
function statusTextColor(mr: TaskMR): string {
if (mr.state === "merged") return "text-purple-500";
if (mr.state === "closed") return "text-muted-foreground";
if (mr.pipeline_state === "failure") return "text-red-500";
if (mr.approval_state === "approved") return "text-emerald-400";
if (mr.approval_state === "pending") return "text-sky-400";
return "text-muted-foreground";
}
export function mrTriggerClass(compact: boolean, mobile: boolean): string {
if (mobile) return "h-11 w-11 cursor-pointer";
return compact ? "h-9 w-9 cursor-pointer" : "cursor-pointer gap-1.5 px-2";
}
export function openMobileMRReview(
setReview: (sessionId: string, mrKey: string) => void,
sessionId: string,
mr: TaskMR,
) {
setReview(sessionId, mrTaskKey(mr));
}
export function openDesktopMRReview(
addMRPanel: (mrKey: string, sessionId?: string | null) => void,
sessionId: string | null,
mr: TaskMR,
schedule: (callback: FrameRequestCallback) => number = requestAnimationFrame,
) {
const mrKey = mrTaskKey(mr);
addMRPanel(mrKey, sessionId);
schedule(() => {
schedule(() => addMRPanel(mrKey, sessionId));
});
}
function MRTriggerContent({
compact,
single,
count,
}: {
compact: boolean;
single: TaskMR | null;
count: number;
}) {
if (compact) return <IconBrandGitlab className="h-4 w-4 text-orange-500" />;
if (single) {
return (
<>
<IconGitMerge className={`h-4 w-4 ${statusTextColor(single)}`} />
<span className="text-xs font-medium">!{single.mr_iid}</span>
<MRStatusIcon mr={single} />
</>
);
}
return (
<>
<IconBrandGitlab className="h-4 w-4 text-orange-500" />
<span className="text-xs font-medium">{count} MRs</span>
</>
);
}
function MRMenuButton({
mrs,
canLink,
compact,
mobile,
onLink,
onUnlink,
}: {
mrs: TaskMR[];
canLink: boolean;
compact: boolean;
mobile: boolean;
onLink: () => void;
onUnlink: (associationId: string) => void;
}) {
const single = mrs.length === 1 ? mrs[0] : null;
const addMRPanel = useDockviewStore((state) => state.addMRPanel);
const dockviewReady = useDockviewStore((state) => state.api !== null);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const setMobileSessionReview = useAppStore((state) => state.setMobileSessionReview);
const [pendingDesktopMR, setPendingDesktopMR] = useState<TaskMR | null>(null);
useEffect(() => {
if (!dockviewReady || !pendingDesktopMR) return;
openDesktopMRReview(addMRPanel, activeSessionId, pendingDesktopMR);
setPendingDesktopMR(null);
}, [activeSessionId, addMRPanel, dockviewReady, pendingDesktopMR]);
const openReview = (mr: TaskMR) => {
if (mobile) {
if (activeSessionId) openMobileMRReview(setMobileSessionReview, activeSessionId, mr);
return;
}
if (!dockviewReady) {
setPendingDesktopMR(mr);
} else {
openDesktopMRReview(addMRPanel, activeSessionId, mr);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="mr-topbar-button"
data-mr-iid={single?.mr_iid}
data-mr-state={single?.state}
size={compact ? "icon-sm" : "sm"}
variant="outline"
className={mrTriggerClass(compact, mobile)}
aria-label={
single
? `GitLab merge request !${single.mr_iid}`
: `${mrs.length} GitLab merge requests`
}
>
<MRTriggerContent compact={compact} single={single} count={mrs.length} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
{mrs.map((mr) => (
<div key={mr.id}>
<DropdownMenuItem className="cursor-pointer" onSelect={() => openReview(mr)}>
<IconGitMerge className="h-4 w-4" />
<span className="min-w-0 truncate">
Review {mr.project_path}!{mr.mr_iid}
</span>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href={mr.mr_url} target="_blank" rel="noopener noreferrer">
<IconExternalLink className="h-4 w-4" /> Open in GitLab
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
onSelect={() => onUnlink(mr.id)}
>
<IconUnlink className="h-4 w-4" />
Unlink !{mr.mr_iid}
</DropdownMenuItem>
</div>
))}
{canLink ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" onSelect={onLink}>
<IconPlus className="h-4 w-4" />
Link another merge request
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
const EMPTY_REPOSITORIES: Repository[] = [];
const EMPTY_TASK_REPOSITORIES: Array<{ repository_id: string }> = [];
function MRTopbarControl({
mrs,
gitlabAvailable,
compact,
mobile,
onLink,
onUnlink,
}: {
mrs: TaskMR[];
gitlabAvailable: boolean;
compact: boolean;
mobile: boolean;
onLink: () => void;
onUnlink: (associationId: string) => void;
}) {
if (mrs.length > 0) {
return (
<MRMenuButton
mrs={mrs}
canLink={gitlabAvailable}
compact={compact}
mobile={mobile}
onLink={onLink}
onUnlink={onUnlink}
/>
);
}
return null;
}
export const MRTopbarButton = memo(function MRTopbarButton({
compact = false,
mobile = false,
}: {
compact?: boolean;
mobile?: boolean;
}) {
const [linkOpen, setLinkOpen] = useState(false);
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const repositories = useAppStore((state) =>
workspaceId
? (state.repositories.itemsByWorkspaceId[workspaceId] ?? EMPTY_REPOSITORIES)
: EMPTY_REPOSITORIES,
);
const task = useTaskById(activeTaskId);
useWorkspaceMRs(workspaceId);
const mrs = useTaskMRs(activeTaskId);
const gitlabAvailable = useGitLabAvailable();
const removeTaskMR = useAppStore((state) => state.removeTaskMR);
const { toast } = useToast();
if (!activeTaskId || !workspaceId) return null;
const unlink = async (associationId: string) => {
try {
await deleteTaskMR(associationId, workspaceId);
removeTaskMR(workspaceId, associationId);
} catch (error) {
toast({
title: "Failed to unlink merge request",
description: error instanceof Error ? error.message : "The merge request is still linked.",
variant: "error",
});
}
};
return (
<>
<MRTopbarControl
mrs={mrs}
gitlabAvailable={gitlabAvailable}
compact={compact}
mobile={mobile}
onLink={() => setLinkOpen(true)}
onUnlink={(associationId) => void unlink(associationId)}
/>
<TaskMRLinkDialog
open={linkOpen}
onOpenChange={setLinkOpen}
taskId={activeTaskId}
workspaceId={workspaceId}
taskRepositories={task?.repositories ?? EMPTY_TASK_REPOSITORIES}
repositories={repositories}
/>
</>
);
});