Files
kandev/apps/web/components/task/base-branch-picker.tsx
T
2026-07-31 10:55:29 +01:00

278 lines
9.1 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 Branch, type Repository } from "@/lib/types/http";
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 { 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: "Failed to change compare branch",
description: err instanceof Error ? err.message : "Unknown error",
variant: "error",
});
} finally {
setIsSaving(false);
setOpen(false);
}
};
return {
open,
setOpen,
isSaving,
resolved,
branches,
isLoadingBranches,
currentBase,
handleSelect,
};
}
function BranchList({
branches,
isLoadingBranches,
currentBase,
onSelect,
}: {
branches: Branch[];
isLoadingBranches: boolean;
currentBase: string;
onSelect: (name: string) => void;
}) {
const [filter, setFilter] = useState("");
// Dedupe by name (local + remote variants collapse to one option) so the
// list shows each branch once even when the API returns both kinds.
const uniqueByName = useMemo(() => {
const seen = new Set<string>();
const out: Branch[] = [];
for (const b of branches) {
if (seen.has(b.name)) continue;
seen.add(b.name);
out.push(b);
}
return out;
}, [branches]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return uniqueByName;
return uniqueByName.filter((b) => b.name.toLowerCase().includes(q));
}, [filter, uniqueByName]);
return (
<div className="flex flex-col gap-1">
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter branches…"
data-testid="base-branch-picker-filter"
className="w-full rounded border border-input bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
<BranchListBody
filtered={filtered}
isLoadingBranches={isLoadingBranches}
currentBase={currentBase}
onSelect={onSelect}
/>
</div>
);
}
function BranchListBody({
filtered,
isLoadingBranches,
currentBase,
onSelect,
}: {
filtered: Branch[];
isLoadingBranches: boolean;
currentBase: string;
onSelect: (name: string) => void;
}) {
if (isLoadingBranches && filtered.length === 0) {
return (
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
<IconLoader2 className="h-3 w-3 animate-spin" />
Loading branches…
</div>
);
}
if (filtered.length === 0) {
return <div className="px-2 py-1.5 text-xs text-muted-foreground">No matching branches</div>;
}
return (
<>
{filtered.map((b) => (
<button
key={`${b.type}:${b.name}`}
type="button"
role="option"
aria-selected={b.name === currentBase}
data-testid={`base-branch-picker-option-${b.name}`}
className={cn(
"flex w-full items-center gap-2 rounded border border-transparent px-2 py-1.5 text-left text-xs cursor-pointer hover:bg-muted",
b.name === currentBase && "border-primary/50 bg-card font-medium",
)}
onClick={() => onSelect(b.name)}
>
<span className="truncate">{b.name}</span>
</button>
))}
</>
);
}
/**
* 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()}
>
<BranchList
branches={logic.branches}
isLoadingBranches={logic.isLoadingBranches}
currentBase={logic.currentBase}
onSelect={logic.handleSelect}
/>
</PopoverContent>
</Popover>
);
}