Files
kandev/apps/web/components/task/commit-row.tsx
T
Kandev Agent 9e8079de69 feat(multi-repo): per-repo git ops, unified branch endpoint, chip-style task create
Bundles a string of multi-repo fixes and refactors that came up while
testing the feature end-to-end:

- workspace_path stored on multi-repo TaskEnvironment was one level too
  high, so agentctl booted with the wrong WorkDir on cold start; fixed
  in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession.
- Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo`
  through agentctl client → WS handler → frontend hook → SessionGit
  fan-out, so the right git repo is targeted (was running at the task
  root, which isn't a git repo for multi-repo tasks).
- agentctl GitLog is multi-repo aware: when no `repo` is specified and
  per-repo trackers exist, fans out one log per repo and merges sorted
  newest-first with RepositoryName stamped on each commit.
- Per-repo grouping for files (Changes panel) and commits, with
  collapsible group headers; commits section defaults to collapsed.
  Algorithm extracted into one generic groupByRepositoryName helper
  (was copy-pasted three times).
- Task creation dialog rewritten around a unified `fs.repositories[]`
  list — no more "primary vs extras" split. Chip-style row with one
  pill per repo + branch, "+" to add, x to remove, GitHub URL toggle
  inline. Discovered (on-machine) repos appear in any chip's dropdown.
  Removes ~6 fields and 3 helpers from the dialog state.
- Branch listing endpoints collapsed to one:
  GET /workspaces/:id/branches?repository_id=X | path=Y
  Frontend collapses to a single useBranches hook with a tagged-union
  source. Drops two HTTP routes, two service methods, two API client
  funcs, and one whole hook file.

Tests added across the chain: workspace_path resolver, sort-commits-
by-date, manager poll-mode propagation, RepoSubpaths, chip grouping
+ collapse + per-repo isLatest, group-by-repo helper, dialog payload
builder + chip wiring + discovered-repo flow.
2026-04-26 21:23:48 +01:00

240 lines
7.7 KiB
TypeScript

"use client";
import {
IconGitCommit,
IconArrowBackUp,
IconPencil,
IconHistoryToggle,
IconArrowUp,
} from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@kandev/ui/context-menu";
export type CommitItem = {
commit_sha: string;
commit_message: string;
insertions: number;
deletions: number;
pushed?: boolean;
/** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */
repository_name?: string;
};
/** Context menu for commit items */
function CommitContextMenu({
children,
commit,
isLatest,
onAmendCommit,
onRevertCommit,
onResetToCommit,
}: {
children: React.ReactNode;
commit: CommitItem;
isLatest: boolean;
// Multi-repo: handlers receive the commit's repository_name so the
// amend/revert/reset op runs in the right git repo. Without it, ops hit
// the workspace root which fails on multi-repo task workspaces.
onAmendCommit?: (currentMessage: string, repo?: string) => void;
onRevertCommit?: (sha: string, repo?: string) => void;
onResetToCommit?: (sha: string, repo?: string) => void;
}) {
const hasActions = onAmendCommit || onRevertCommit || onResetToCommit;
if (!hasActions) {
return <>{children}</>;
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
{isLatest && onAmendCommit && (
<ContextMenuItem
onSelect={() => onAmendCommit(commit.commit_message, commit.repository_name)}
>
<IconPencil className="h-3.5 w-3.5" />
Amend message
</ContextMenuItem>
)}
{isLatest && onRevertCommit && (
<ContextMenuItem
onSelect={() => onRevertCommit(commit.commit_sha, commit.repository_name)}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
Revert commit
</ContextMenuItem>
)}
{onResetToCommit && (
<ContextMenuItem
onSelect={() => onResetToCommit(commit.commit_sha, commit.repository_name)}
>
<IconHistoryToggle className="h-3.5 w-3.5" />
Reset to this commit
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
/** Hover action buttons for a commit row */
function CommitRowActions({
commit,
isLatest,
onAmendCommit,
onRevertCommit,
onResetToCommit,
}: {
commit: CommitItem;
isLatest: boolean;
// Multi-repo: handlers receive the commit's repository_name so the
// amend/revert/reset op runs in the right git repo. Without it, ops hit
// the workspace root which fails on multi-repo task workspaces.
onAmendCommit?: (currentMessage: string, repo?: string) => void;
onRevertCommit?: (sha: string, repo?: string) => void;
onResetToCommit?: (sha: string, repo?: string) => void;
}) {
return (
<span className="absolute right-2 top-0 bottom-0 hidden group-hover:flex items-center gap-1">
{isLatest && onAmendCommit && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Amend commit message"
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onAmendCommit(commit.commit_message, commit.repository_name);
}}
>
<IconPencil className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Amend commit message</TooltipContent>
</Tooltip>
)}
{isLatest && onRevertCommit && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Revert commit"
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onRevertCommit(commit.commit_sha, commit.repository_name);
}}
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Revert commit</TooltipContent>
</Tooltip>
)}
{onResetToCommit && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Reset to this commit"
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onResetToCommit(commit.commit_sha, commit.repository_name);
}}
>
<IconHistoryToggle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Reset to this commit</TooltipContent>
</Tooltip>
)}
</span>
);
}
/** Individual commit row with hover actions */
export function CommitRow({
commit,
isLatest,
onOpenCommitDetail,
onAmendCommit,
onRevertCommit,
onResetToCommit,
}: {
commit: CommitItem;
isLatest: boolean;
onOpenCommitDetail?: (sha: string) => void;
// Multi-repo: handlers receive the commit's repository_name so the
// amend/revert/reset op runs in the right git repo. Without it, ops hit
// the workspace root which fails on multi-repo task workspaces.
onAmendCommit?: (currentMessage: string, repo?: string) => void;
onRevertCommit?: (sha: string, repo?: string) => void;
onResetToCommit?: (sha: string, repo?: string) => void;
}) {
const showActions = onResetToCommit || (isLatest && (onAmendCommit || onRevertCommit));
return (
<CommitContextMenu
commit={commit}
isLatest={isLatest}
onAmendCommit={onAmendCommit}
onRevertCommit={onRevertCommit}
onResetToCommit={onResetToCommit}
>
<li
role="button"
tabIndex={0}
data-testid={`commit-row-${commit.commit_sha.slice(0, 7)}`}
className="group relative flex items-center gap-2 text-xs rounded-md px-1 py-1 -mx-1 hover:bg-muted/60 cursor-pointer"
onClick={() => onOpenCommitDetail?.(commit.commit_sha)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenCommitDetail?.(commit.commit_sha);
}
}}
>
<span
className="shrink-0"
title={commit.pushed === true ? "Pushed to remote" : "Local commit (not yet pushed)"}
>
<span className="sr-only">
{commit.pushed === true ? "Pushed commit" : "Unpushed commit"}
</span>
{commit.pushed === true ? (
<IconGitCommit aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<IconArrowUp aria-hidden="true" className="h-3.5 w-3.5 text-emerald-500" />
)}
</span>
<code className="font-mono text-muted-foreground text-[11px]">
{commit.commit_sha.slice(0, 7)}
</code>
<span className="flex-1 min-w-0 truncate text-foreground">{commit.commit_message}</span>
<span className="shrink-0 text-[11px] flex items-center gap-1 mr-1 group-hover:opacity-0">
<span className="text-emerald-500">+{commit.insertions}</span>{" "}
<span className="text-rose-500">-{commit.deletions}</span>
</span>
{showActions && (
<CommitRowActions
commit={commit}
isLatest={isLatest}
onAmendCommit={onAmendCommit}
onRevertCommit={onRevertCommit}
onResetToCommit={onResetToCommit}
/>
)}
</li>
</CommitContextMenu>
);
}