Files
kandev/apps/web/components/task/base-branch-picker.tsx
T
2026-08-21 16:39:47 +01:00

186 lines
6.5 KiB
TypeScript

"use client";
import { useState, useMemo } from "react";
import { IconChevronDown, IconLoader2 } from "@tabler/icons-react";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/components/state-provider";
import { useBranches } from "@/hooks/domains/workspace/use-repository-branches";
import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
import { invalidateCumulativeDiffCache } from "@/hooks/domains/session/use-cumulative-diff";
import { updateTaskRepositoryBaseBranch } from "@/lib/api/domains/kanban-api";
import { useToast } from "@/components/toast-provider";
import { repositoryId, type Repository } from "@/lib/types/http";
import { BranchPickerList } from "./branch-picker-list";
import { useTranslation } from "react-i18next";
type ResolvedRepo = {
taskRepositoryId: string;
storedBase: string;
workspaceId: string;
repositoryId: string;
};
/**
* Resolves the WorkspaceTracker's `repositoryName` (= worktree dir basename)
* to the task_repositories row + workspace Repository pair. For single-repo
* tasks the empty `repositoryName` falls back to the only row.
*/
function useResolvedTaskRepo(taskId: string | null, repositoryName: string): ResolvedRepo | null {
const task = useAppStore((s) => (taskId ? s.kanban.tasks.find((t) => t.id === taskId) : null));
const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId);
return useMemo(() => {
if (!task?.repositories?.length) return null;
const allRepos = Object.values(reposByWorkspace).flat() as Repository[];
if (repositoryName === "" && task.repositories.length === 1) {
const link = task.repositories[0]!;
const repo = allRepos.find((r) => r.id === repositoryId(link.repository_id));
return repo
? {
taskRepositoryId: link.id,
storedBase: link.base_branch,
workspaceId: repo.workspace_id,
repositoryId: repo.id,
}
: null;
}
const repo = allRepos.find((r) => r.name === repositoryName);
if (!repo) return null;
const link = task.repositories.find((l) => repositoryId(l.repository_id) === repo.id);
return link
? {
taskRepositoryId: link.id,
storedBase: link.base_branch,
workspaceId: repo.workspace_id,
repositoryId: repo.id,
}
: null;
}, [task, reposByWorkspace, repositoryName]);
}
/**
* Shared open/save/handleSelect/branch-load logic used by both picker
* variants (inline span replacement + always-visible header button). Keeps
* the trigger components tiny and avoids duplicated select + toast plumbing.
*/
function usePickerLogic(taskId: string | null, repositoryName: string, fallbackBaseBranch: string) {
const { t } = useTranslation();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const resolved = useResolvedTaskRepo(taskId, repositoryName);
const sessionId = useEnvironmentSessionId();
const envKey = useAppStore((s) =>
sessionId ? (s.environmentIdBySessionId[sessionId] ?? sessionId) : null,
);
const bumpSessionCommitsRefetch = useAppStore((s) => s.bumpSessionCommitsRefetch);
const { branches, isLoading: isLoadingBranches } = useBranches(
resolved
? { kind: "id", workspaceId: resolved.workspaceId, repositoryId: resolved.repositoryId }
: null,
open,
);
const currentBase = resolved?.storedBase || fallbackBaseBranch;
const handleSelect = async (branch: string) => {
// currentBase falls back to fallbackBaseBranch when storedBase is empty
// (legacy task before any picker change). Compare against currentBase so
// selecting the displayed value is treated as a no-op instead of saving
// a spurious update.
if (!resolved || !taskId || branch === currentBase) {
setOpen(false);
return;
}
setIsSaving(true);
try {
await updateTaskRepositoryBaseBranch(taskId, resolved.taskRepositoryId, branch);
// Backend already cleared session.base_commit_sha and rewrote
// session.base_branch; nudge the client-side caches so the commits
// panel + cumulative diff refetch against the new base instead of
// serving the stale snapshot until next page load.
if (sessionId) bumpSessionCommitsRefetch(sessionId);
if (envKey) invalidateCumulativeDiffCache(envKey);
} catch (err) {
toast({
title: t("task:failedToChangeCompareBranch"),
description: err instanceof Error ? err.message : t("task:unknownError"),
variant: "error",
});
} finally {
setIsSaving(false);
setOpen(false);
}
};
return {
open,
setOpen,
isSaving,
resolved,
branches,
isLoadingBranches,
currentBase,
handleSelect,
};
}
/**
* Compare-against picker rendered inline inside the branch hover card.
* Replaces the static base branch label with a click-to-edit trigger.
*/
export function BaseBranchPicker({
taskId,
repositoryName,
fallbackBaseBranch,
}: {
taskId: string | null;
repositoryName: string;
fallbackBaseBranch: string;
}) {
const logic = usePickerLogic(taskId, repositoryName, fallbackBaseBranch);
if (!logic.resolved) {
return <span className="text-foreground font-medium">{logic.currentBase}</span>;
}
return (
<Popover open={logic.open} onOpenChange={logic.setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-haspopup="listbox"
aria-expanded={logic.open}
data-testid="base-branch-picker-trigger"
className={cn(
"inline-flex items-center gap-1 cursor-pointer rounded px-1 py-px text-foreground font-medium hover:bg-muted",
logic.isSaving && "opacity-60 cursor-wait",
)}
disabled={logic.isSaving}
>
<span className="truncate max-w-[12rem]">{logic.currentBase}</span>
{logic.isSaving ? (
<IconLoader2 className="h-2.5 w-2.5 animate-spin" />
) : (
<IconChevronDown className="h-2.5 w-2.5 opacity-50" />
)}
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-64 p-1 max-h-72 overflow-auto"
role="listbox"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<BranchPickerList
branches={logic.branches}
isLoadingBranches={logic.isLoadingBranches}
currentBase={logic.currentBase}
onSelect={logic.handleSelect}
/>
</PopoverContent>
</Popover>
);
}