Files
kandev/apps/web/components/vcs/vcs-dialogs.tsx
T
2026-08-26 11:09:00 +00:00

589 lines
20 KiB
TypeScript

"use client";
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from "react";
import { IconGitCommit, IconLoader2, IconCheck } from "@tabler/icons-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import { GenerateButton, CommitBodyField } from "./vcs-dialog-fields";
import { VcsChangeRequestDialog } from "./vcs-change-request-dialog";
import {
useSessionGitStatus,
useSessionGitStatusByRepo,
} from "@/hooks/domains/session/use-session-git-status";
import { useSessionGit } from "@/hooks/domains/session/use-session-git";
import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name";
import { useChangeRequestTerminology } from "@/hooks/use-git-operations";
import { gitOperationLabel, useGitWithFeedback } from "@/hooks/use-git-with-feedback";
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import type { FileInfo } from "@/lib/state/slices";
import { Trans, useTranslation } from "react-i18next";
import { createChangeRequestWithProvider } from "@/lib/plugins/change-request-creation";
import { useChangeRequestProviderTarget } from "@/hooks/use-change-request-provider-target";
import {
useCreateChangeRequestHandler,
type CreateChangeRequestInput,
} from "./use-create-change-request-handler";
type VcsDialogsContextValue = {
/** When `repo` is provided, the commit is scoped to that repo only. */
openCommitDialog: (repo?: string) => void;
/** When `repo` is provided, the PR is scoped to that repo only. */
openPRDialog: (repo?: string) => void;
};
const VcsDialogsContext = createContext<VcsDialogsContextValue | null>(null);
export function useVcsDialogs() {
const ctx = useContext(VcsDialogsContext);
if (!ctx) throw new Error("useVcsDialogs must be used within VcsDialogsProvider");
return ctx;
}
type VcsDialogsProviderProps = {
sessionId: string | null;
baseBranch?: string;
pullRequestBaseBranch?: string;
pullRequestTargetsByRepository?: Record<string, string>;
taskTitle?: string;
displayBranch?: string | null;
children: ReactNode;
};
export function resolvePullRequestBaseBranch(
selectedRepo: string | undefined,
targetsByRepository: Record<string, string> | undefined,
fallback: string | undefined,
): string | undefined {
if (!selectedRepo || !targetsByRepository) return fallback;
const direct = targetsByRepository[selectedRepo];
if (direct) return direct;
const selectedBase = selectedRepo.split(" · ")[0];
const baseTarget = targetsByRepository[selectedBase];
if (baseTarget) return baseTarget;
return fallback;
}
type FileSummary = { count: number; additions: number; deletions: number };
/**
* Counts files for the commit dialog summary.
* - When `stageAll=true`, include every file (staged + unstaged) because the
* commit op stages them all before committing.
* - When `stageAll=false` (the default), count only staged files — those are
* the only files the commit will actually include. Counting all here would
* over-state what the commit produces and surprise the user post-commit.
*/
function computeFileSummary(
files: Record<string, FileInfo> | undefined,
stageAll: boolean = false,
): FileSummary {
if (!files) return { count: 0, additions: 0, deletions: 0 };
const considered = (Object.values(files) as FileInfo[]).filter((f) => stageAll || f.staged);
let additions = 0;
let deletions = 0;
for (const file of considered) {
additions += file.additions || 0;
deletions += file.deletions || 0;
}
return { count: considered.length, additions, deletions };
}
function FileSummaryText({ count, additions, deletions }: FileSummary) {
const { t } = useTranslation();
if (count === 0) return <span>{t("integrations:noChangesToCommit")}</span>;
return (
<span>
<Trans i18nKey="integrations:filesChanged" count={count} values={{ count }}>
<span className="font-medium text-foreground">{count}</span> files changed
</Trans>
{(additions > 0 || deletions > 0) && (
<span className="ml-2">
(<span className="text-green-600">+{additions}</span>
{" / "}
<span className="text-red-600">-{deletions}</span>)
</span>
)}
</span>
);
}
type CommitDialogProps = {
open: boolean;
onOpenChange: (v: boolean) => void;
/** When set, the dialog title shows the repo name and the summary is repo-scoped. */
scopedRepo?: string;
fileSummary: FileSummary;
commitMessage: string;
onCommitMessageChange: (v: string) => void;
commitBody: string;
onCommitBodyChange: (v: string) => void;
stageAll: boolean;
onStageAllChange: (v: boolean) => void;
isGitLoading: boolean;
onCommit: () => void;
onGenerateMessage: () => void;
isGenerating: boolean;
onGenerateDescription: () => void;
isGeneratingDescription: boolean;
isUtilityConfigured: boolean;
};
function CommitDialog({
open,
onOpenChange,
scopedRepo,
fileSummary,
commitMessage,
onCommitMessageChange,
commitBody,
onCommitBodyChange,
stageAll,
onStageAllChange,
isGitLoading,
onCommit,
onGenerateMessage,
isGenerating,
onGenerateDescription,
isGeneratingDescription,
isUtilityConfigured,
}: CommitDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitCommit className="h-5 w-5" />
{scopedRepo
? t("integrations:commitChangesScoped", { scopedRepo })
: t("integrations:commitChanges")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="text-sm text-muted-foreground">
<FileSummaryText {...fileSummary} />
</div>
<div className="relative min-w-0">
<Input
data-testid="commit-title-input"
placeholder={t("integrations:enterCommitMessage")}
value={commitMessage}
onChange={(e) => onCommitMessageChange(e.target.value)}
className="pr-10"
autoFocus
/>
<div className="absolute right-1.5 top-1/2 -translate-y-1/2">
<GenerateButton
onClick={onGenerateMessage}
isGenerating={isGenerating}
disabled={fileSummary.count === 0}
tooltip={t("integrations:generateCommitMessageWithAi")}
isConfigured={isUtilityConfigured}
/>
</div>
</div>
<CommitBodyField
commitBody={commitBody}
onCommitBodyChange={onCommitBodyChange}
onGenerateDescription={onGenerateDescription}
isGeneratingDescription={isGeneratingDescription}
isUtilityConfigured={isUtilityConfigured}
disabled={fileSummary.count === 0}
/>
<div className="flex items-center gap-2">
<Checkbox
id="vcs-stage-all"
checked={stageAll}
onCheckedChange={(checked) => onStageAllChange(checked === true)}
/>
<Label htmlFor="vcs-stage-all" className="text-sm text-muted-foreground cursor-pointer">
{t("integrations:stageAllChangesBeforeCommitting")}
</Label>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" className="cursor-pointer">
{t("common:cancel")}
</Button>
</DialogClose>
<Button onClick={onCommit} disabled={!commitMessage.trim() || isGitLoading}>
{isGitLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
{t("integrations:committingEllipsis")}
</>
) : (
<>
<IconCheck className="h-4 w-4 mr-2" />
{t("integrations:commit")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
type UseCommitDialogReturn = {
open: boolean;
setOpen: (v: boolean) => void;
message: string;
setMessage: (v: string) => void;
body: string;
setBody: (v: string) => void;
stageAll: boolean;
setStageAll: (v: boolean) => void;
/** Undefined means all repos; "" is an explicit workspace-root scope. */
repo: string | undefined;
setRepo: (v: string | undefined) => void;
openDialog: (repo?: string) => void;
};
function useCommitDialogState(): UseCommitDialogReturn {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [body, setBody] = useState("");
const [stageAll, setStageAll] = useState(false);
const [repo, setRepo] = useState<string | undefined>(undefined);
const openDialog = useCallback((nextRepo?: string) => {
setMessage("");
setBody("");
setStageAll(false);
// Defensive: callers binding `openDialog` directly to onClick can leak the
// React MouseEvent into nextRepo. Only accept actual repo strings.
setRepo(typeof nextRepo === "string" ? nextRepo : undefined);
setOpen(true);
}, []);
return {
open,
setOpen,
message,
setMessage,
body,
setBody,
stageAll,
setStageAll,
repo,
setRepo,
openDialog,
};
}
type UsePRDialogReturn = {
open: boolean;
setOpen: (v: boolean) => void;
title: string;
setTitle: (v: string) => void;
body: string;
setBody: (v: string) => void;
draft: boolean;
setDraft: (v: boolean) => void;
branchPushed: boolean;
setBranchPushed: (v: boolean) => void;
/** Undefined means the default scope; "" is an explicit workspace-root scope. */
repo: string | undefined;
setRepo: (v: string | undefined) => void;
openDialog: (taskTitle?: string, repo?: string) => void;
};
function usePRDialogState(): UsePRDialogReturn {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [draft, setDraft] = useState(true);
const [branchPushed, setBranchPushed] = useState(false);
const [repo, setRepo] = useState<string | undefined>(undefined);
const openDialog = useCallback((taskTitle?: string, nextRepo?: string) => {
setTitle(taskTitle || "");
setBody("");
setBranchPushed(false);
// Defensive: callers binding `openDialog` directly to onClick can leak the
// React MouseEvent into nextRepo. Only accept actual repo strings.
setRepo(typeof nextRepo === "string" ? nextRepo : undefined);
setOpen(true);
}, []);
return {
open,
setOpen,
title,
setTitle,
body,
setBody,
draft,
setDraft,
branchPushed,
setBranchPushed,
repo,
setRepo,
openDialog,
};
}
/**
* Computes the file summary for the commit dialog: an explicit repo scope
* uses that repo's files; no scope in multi-repo sums across every repo
* (showing the fan-out total); single-repo falls back to the legacy
* workspace-level status.
*/
function useScopedFileSummary({
scopedRepo,
statusByRepo,
gitStatus,
isMultiRepo,
stageAll,
}: {
scopedRepo: string | undefined;
statusByRepo: ReturnType<typeof useSessionGitStatusByRepo>;
gitStatus: ReturnType<typeof useSessionGitStatus>;
isMultiRepo: boolean;
/** Mirrors the dialog's "Stage all changes before committing" checkbox. */
stageAll: boolean;
}): FileSummary {
return useMemo(() => {
if (scopedRepo !== undefined) {
const scoped = statusByRepo.find((s) => s.repository_name === scopedRepo);
return computeFileSummary(scoped?.status?.files, stageAll);
}
if (isMultiRepo) {
let count = 0;
let additions = 0;
let deletions = 0;
for (const { status } of statusByRepo) {
const s = computeFileSummary(status?.files, stageAll);
count += s.count;
additions += s.additions;
deletions += s.deletions;
}
return { count, additions, deletions };
}
return computeFileSummary(gitStatus?.files, stageAll);
}, [scopedRepo, statusByRepo, gitStatus, isMultiRepo, stageAll]);
}
/**
* Resolves the label shown in dialog titles. Explicit repo wins; otherwise
* empty scope resolves to the primary single-repo display name, or "All
* repos" when the workspace has multiple repos and the dialog is fanning out.
*/
export function pickRepoLabel(
scopedRepo: string | undefined,
isMultiRepo: boolean,
resolveDisplayName: (name: string) => string | undefined,
// Structural, matching the other translate-taking helpers in the app: this
// only ever looks a key up, so both the hook's `t` and the module-level one
// from `@/lib/i18n` qualify. The branded `TFunction` excluded the latter.
t: (key: string) => string,
): string {
if (scopedRepo !== undefined) {
return scopedRepo
? resolveDisplayName(scopedRepo) || scopedRepo
: resolveDisplayName("") || t("integrations:repository");
}
if (isMultiRepo) return t("integrations:allRepos");
return resolveDisplayName("") || t("integrations:repository");
}
// eslint-disable-next-line max-lines-per-function -- Coordinates the shared commit and change-request surfaces.
function useVcsDialogsState(
sessionId: string | null,
taskTitle: string | undefined,
baseBranch: string | undefined,
pullRequestBaseBranch: string | undefined,
pullRequestTargetsByRepository: Record<string, string> | undefined,
) {
const { t } = useTranslation();
const cs = useCommitDialogState();
const ps = usePRDialogState();
const defaultPullRequestBaseBranch = pullRequestBaseBranch ?? baseBranch;
const effectivePullRequestBaseBranch = resolvePullRequestBaseBranch(
ps.repo,
pullRequestTargetsByRepository,
defaultPullRequestBaseBranch,
);
const gitWithFeedback = useGitWithFeedback();
const gitStatus = useSessionGitStatus(sessionId);
const statusByRepo = useSessionGitStatusByRepo(sessionId);
// Use SessionGit so commit fans out per-repo for multi-repo workspaces.
// useGitOperations.commit hits the workspace root, which fails for multi-repo
// tasks because the task root isn't itself a git repo (exit 1).
const {
commit,
createPR: createBuiltInPR,
push,
repoNames,
isLoading: isGitLoading,
} = useSessionGit(sessionId);
const registeredCreateTarget = useChangeRequestProviderTarget(sessionId, ps.repo);
const createPR = useCallback(
(input: CreateChangeRequestInput) => {
if (!registeredCreateTarget || !sessionId) {
return createBuiltInPR(
input.title,
input.body,
input.baseBranch,
input.draft,
input.repositoryScope,
);
}
return createChangeRequestWithProvider({
target: registeredCreateTarget,
push,
repositoryScope: input.repositoryScope,
title: input.title,
body: input.body,
baseBranch: input.baseBranch,
draft: input.draft,
branchAlreadyPushed: input.branchAlreadyPushed,
sessionId,
signal: input.signal,
});
},
[createBuiltInPR, push, registeredCreateTarget, sessionId],
);
const supportsDraft = registeredCreateTarget?.provider.supportsDraft !== false;
const repoDisplayName = useRepoDisplayName(sessionId);
const isMultiRepo = repoNames.length > 1;
const changeRequestTerminology = useChangeRequestTerminology(sessionId, ps.repo);
const fileSummary = useScopedFileSummary({
scopedRepo: cs.repo,
statusByRepo,
gitStatus,
isMultiRepo,
stageAll: cs.stageAll,
});
const handleCommit = useCallback(async () => {
if (!cs.message.trim()) return;
cs.setOpen(false);
const title = cs.message.trim();
const body = cs.body.trim();
const fullMessage = body ? `${title}\n\n${body}` : title;
const label = gitOperationLabel(t, "common:gitOpCommit", cs.repo);
await gitWithFeedback(() => commit(fullMessage, cs.stageAll, false, cs.repo), label);
cs.setMessage("");
cs.setBody("");
cs.setRepo(undefined);
}, [cs, gitWithFeedback, commit, t]);
const handleCreatePR = useCreateChangeRequestHandler({
dialog: ps,
baseBranch: effectivePullRequestBaseBranch,
createChangeRequest: createPR,
defaultTerminology: changeRequestTerminology,
supportsDraft,
});
const contextValue = useMemo(
() => ({
openCommitDialog: cs.openDialog,
openPRDialog: (repo?: string) => ps.openDialog(taskTitle, repo),
}),
[cs.openDialog, ps, taskTitle],
);
return {
cs,
ps,
isGitLoading,
fileSummary,
handleCommit,
handleCreatePR,
contextValue,
pullRequestBaseBranch: effectivePullRequestBaseBranch,
repoDisplayName,
isMultiRepo,
changeRequestTerminology,
supportsDraft,
};
}
export function VcsDialogsProvider({
sessionId,
baseBranch,
pullRequestBaseBranch,
pullRequestTargetsByRepository,
taskTitle,
displayBranch,
children,
}: VcsDialogsProviderProps) {
const { t } = useTranslation();
const state = useVcsDialogsState(
sessionId,
taskTitle,
baseBranch,
pullRequestBaseBranch,
pullRequestTargetsByRepository,
);
const { cs, ps, isGitLoading, fileSummary, handleCommit, handleCreatePR, contextValue } = state;
const effectiveRepoLabel = pickRepoLabel(cs.repo, state.isMultiRepo, state.repoDisplayName, t);
const effectivePRLabel = pickRepoLabel(ps.repo, state.isMultiRepo, state.repoDisplayName, t);
const isUtilityConfigured = useIsUtilityConfigured();
const {
isGeneratingCommitMessage,
isGeneratingCommitDescription,
isGeneratingPRTitle,
isGeneratingPRDescription,
generateCommitMessage,
generateCommitDescription,
generatePRTitle,
generatePRDescription,
} = useUtilityAgentGenerator({ sessionId, taskTitle });
return (
<VcsDialogsContext.Provider value={contextValue}>
{children}
<CommitDialog
open={cs.open}
onOpenChange={cs.setOpen}
scopedRepo={effectiveRepoLabel}
fileSummary={fileSummary}
commitMessage={cs.message}
onCommitMessageChange={cs.setMessage}
commitBody={cs.body}
onCommitBodyChange={cs.setBody}
stageAll={cs.stageAll}
onStageAllChange={cs.setStageAll}
isGitLoading={isGitLoading}
onCommit={handleCommit}
onGenerateMessage={() => generateCommitMessage(cs.setMessage)}
isGenerating={isGeneratingCommitMessage}
onGenerateDescription={() => generateCommitDescription(cs.setBody)}
isGeneratingDescription={isGeneratingCommitDescription}
isUtilityConfigured={isUtilityConfigured}
/>
<VcsChangeRequestDialog
open={ps.open}
onOpenChange={ps.setOpen}
scopedRepo={effectivePRLabel}
displayBranch={displayBranch}
baseBranch={state.pullRequestBaseBranch}
title={ps.title}
onTitleChange={ps.setTitle}
body={ps.body}
onBodyChange={ps.setBody}
draft={ps.draft}
onDraftChange={ps.setDraft}
supportsDraft={state.supportsDraft}
loading={isGitLoading}
branchPushed={ps.branchPushed}
onCreate={handleCreatePR}
onGenerateTitle={() => generatePRTitle(ps.setTitle)}
generatingTitle={isGeneratingPRTitle}
onGenerateDescription={() => generatePRDescription(ps.setBody)}
generatingDescription={isGeneratingPRDescription}
utilityConfigured={isUtilityConfigured}
terminology={state.changeRequestTerminology}
/>
</VcsDialogsContext.Provider>
);
}