492 lines
17 KiB
TypeScript
492 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useRef, useState } from "react";
|
|
import { IconGitFork } from "@tabler/icons-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import type { Repository, RepositorySet } from "@/lib/types/http";
|
|
import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types";
|
|
import { RemoteRepoChipsRow } from "@/components/task-create-dialog-remote-repo-chips";
|
|
import { FolderPicker } from "@/components/folder-picker";
|
|
import { SourceModeSwitch } from "@/components/task-create-dialog-source-mode";
|
|
import { WorkspaceRepoChips } from "@/components/task-create-dialog-workspace-repo-chips";
|
|
import { CreateLocalRepositorySurface } from "@/components/create-local-repository-surface";
|
|
import { RepositorySetsControl } from "@/components/task-create-dialog-repository-sets-control";
|
|
import { SaveRepositorySetDialog } from "@/components/task-create-dialog-repository-sets-save";
|
|
import { SaveRepositorySetMenuAction } from "@/components/task-create-dialog-repository-sets-save-action";
|
|
import type { DirectLocalExecutorSelection } from "@/components/task-create-dialog-handlers";
|
|
import { useTranslation } from "react-i18next";
|
|
import { t } from "@/lib/i18n";
|
|
|
|
type RepoChipsRowProps = {
|
|
fs: DialogFormState;
|
|
repositories: Repository[];
|
|
isTaskStarted: boolean;
|
|
/** Required for loading branches on discovered (path-keyed) rows. */
|
|
workspaceId: string | null;
|
|
/**
|
|
* Per-row repo change handler. Resolves the picked value into either a
|
|
* workspace `repositoryId` or a discovered `localPath` and writes that
|
|
* into the row. Comes from useDialogHandlers so the resolution logic
|
|
* stays in one place.
|
|
*/
|
|
onRowRepositoryChange: (key: string, value: string) => void;
|
|
onRowBranchChange: (key: string, value: string) => void;
|
|
onRowPolicyChange?: (key: string, policyId: string, baseBranch: string) => void;
|
|
onPolicySelected?: () => void;
|
|
/** Toggles the Remote tab on/off. Remote-mode rows live in `fs.remoteRepos`. */
|
|
onToggleRemote?: () => void;
|
|
/**
|
|
* Fresh-branch toggle props. When `freshBranchAvailable` is true the toggle
|
|
* renders inline at the right edge of the chip row so it sits next to the
|
|
* branch pills it affects, instead of taking its own row under the
|
|
* agent/executor selectors.
|
|
*/
|
|
freshBranchAvailable?: boolean;
|
|
freshBranchEnabled?: boolean;
|
|
onToggleFreshBranch?: (enabled: boolean) => void;
|
|
/**
|
|
* When the task runs on the local executor, the chip seeds row.branch with
|
|
* the workspace's current branch (so the user sees what's on disk and the
|
|
* submit payload always carries an explicit value). The chip stays
|
|
* editable — picking a different existing branch triggers `git checkout`
|
|
* server-side; keeping the default skips git ops entirely. Fresh-branch
|
|
* mode is independent: it creates a new branch from a chosen base.
|
|
*/
|
|
isLocalExecutor?: boolean;
|
|
/** "No repository" mode: replace the chip row with a folder picker. */
|
|
onToggleNoRepository?: () => void;
|
|
onWorkspacePathChange?: (value: string) => void;
|
|
lastUsedBranch?: string | null;
|
|
userSettingsLoaded?: boolean;
|
|
localRepositoryCreation?: {
|
|
executorSelection: DirectLocalExecutorSelection | null;
|
|
onCreated: (rowKey: string, repository: Repository) => void;
|
|
};
|
|
onRefreshRepositories?: () => void;
|
|
repositoriesRefreshing?: boolean;
|
|
/**
|
|
* The workspace's repository sets, plus how to apply one. Grouped into a single
|
|
* prop so both surfaces that render this row (task create, new subtask) opt in
|
|
* with one line, and Quick Chat - which renders WorkspaceRepoChips directly -
|
|
* is untouched.
|
|
*/
|
|
repositorySets?: {
|
|
sets: RepositorySet[];
|
|
onApply: (set: RepositorySet) => void;
|
|
/** Present when the current selection can be saved as a new set. */
|
|
save?: {
|
|
workspaceId: string;
|
|
rows: TaskRepoRow[];
|
|
repositories: Repository[];
|
|
isLocalExecutor: boolean;
|
|
freshBranchEnabled: boolean;
|
|
open: boolean;
|
|
setOpen: (open: boolean) => void;
|
|
} | null;
|
|
};
|
|
};
|
|
|
|
function applyRowBranchChange(
|
|
fs: DialogFormState,
|
|
isLocalExecutor: boolean | undefined,
|
|
onRowBranchChange: (key: string, value: string) => void,
|
|
key: string,
|
|
value: string,
|
|
) {
|
|
const hasSavedWorktreeBase =
|
|
!isLocalExecutor && fs.repositories.some((row) => row.key === key && row.baseBranch);
|
|
if (hasSavedWorktreeBase) {
|
|
fs.updateRepository(key, { baseBranch: value || undefined });
|
|
return;
|
|
}
|
|
onRowBranchChange(key, value);
|
|
}
|
|
|
|
function updateSavedBaseBranch(fs: DialogFormState, key: string, value: string) {
|
|
fs.updateRepository(key, { baseBranch: value || undefined });
|
|
}
|
|
|
|
type CreatingRepositoryTarget = { rowKey: string; requestId: number };
|
|
|
|
function useCreatingRepositoryTarget() {
|
|
const [target, setTarget] = useState<CreatingRepositoryTarget | null>(null);
|
|
const targetRef = useRef<CreatingRepositoryTarget | null>(null);
|
|
const nextRequestId = useRef(0);
|
|
const openForRow = useCallback((rowKey: string) => {
|
|
const nextTarget = { rowKey, requestId: ++nextRequestId.current };
|
|
targetRef.current = nextTarget;
|
|
setTarget(nextTarget);
|
|
}, []);
|
|
const clear = useCallback(() => {
|
|
const rowKey = targetRef.current?.rowKey ?? null;
|
|
targetRef.current = null;
|
|
setTarget(null);
|
|
return rowKey;
|
|
}, []);
|
|
return { target, targetRef, openForRow, clear };
|
|
}
|
|
|
|
function LocalRepositoryCreationSurface({
|
|
creation,
|
|
target,
|
|
targetRef,
|
|
workspaceId,
|
|
multiRow,
|
|
onOpenChange,
|
|
}: {
|
|
creation: RepoChipsRowProps["localRepositoryCreation"];
|
|
target: CreatingRepositoryTarget | null;
|
|
targetRef: { current: CreatingRepositoryTarget | null };
|
|
workspaceId: string | null;
|
|
multiRow: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
if (!creation) return null;
|
|
return (
|
|
<CreateLocalRepositorySurface
|
|
open={target !== null}
|
|
onOpenChange={onOpenChange}
|
|
workspaceId={workspaceId}
|
|
executorSelection={creation.executorSelection}
|
|
context={multiRow ? "task-create-multi" : "task-create"}
|
|
onCreated={(repository) => {
|
|
if (!target) return false;
|
|
const isCurrentRequest = targetRef.current?.requestId === target.requestId;
|
|
creation.onCreated(target.rowKey, repository);
|
|
return isCurrentRequest;
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function RepoChipsRow({
|
|
fs,
|
|
repositories,
|
|
isTaskStarted,
|
|
workspaceId,
|
|
onRowRepositoryChange,
|
|
onRowBranchChange,
|
|
onRowPolicyChange,
|
|
onPolicySelected,
|
|
onToggleRemote,
|
|
freshBranchAvailable,
|
|
freshBranchEnabled,
|
|
onToggleFreshBranch,
|
|
isLocalExecutor,
|
|
onToggleNoRepository,
|
|
onWorkspacePathChange,
|
|
lastUsedBranch,
|
|
userSettingsLoaded,
|
|
localRepositoryCreation,
|
|
onRefreshRepositories,
|
|
repositoriesRefreshing,
|
|
repositorySets,
|
|
}: RepoChipsRowProps) {
|
|
const chipRowRef = useRef<HTMLDivElement>(null);
|
|
const { target, targetRef, openForRow, clear } = useCreatingRepositoryTarget();
|
|
const handleCreationOpenChange = (open: boolean) => {
|
|
if (open || target === null) return;
|
|
const rowKey = clear();
|
|
if (rowKey === null) return;
|
|
requestAnimationFrame(() => {
|
|
const row = Array.from(
|
|
chipRowRef.current?.querySelectorAll<HTMLElement>("[data-repo-row-key]") ?? [],
|
|
).find((candidate) => candidate.dataset.repoRowKey === rowKey);
|
|
row?.querySelector<HTMLElement>("[data-testid='repo-chip-trigger']")?.focus();
|
|
});
|
|
};
|
|
// Local executor branch behavior:
|
|
// - chip is clickable (user can switch to any existing branch on disk)
|
|
// - row.branch seeds from the workspace's current branch (currentLocalBranch)
|
|
// via the autoselect path, so the chip displays the current branch by
|
|
// default and the submit payload always carries an explicit value
|
|
// - if user keeps the default, backend's "branch == current → skip" logic
|
|
// runs (no git ops)
|
|
// - if user picks a different existing branch, backend runs `git checkout`
|
|
// - "Fork a new branch" toggle is a separate flow that creates a NEW branch
|
|
// from the selected base
|
|
// Other executors: branch is fully editable (no special pre-fill).
|
|
const handleRowBranchChange = (key: string, value: string) =>
|
|
applyRowBranchChange(fs, isLocalExecutor, onRowBranchChange, key, value);
|
|
// No early returns above hooks. URL mode and started-state checks happen below.
|
|
if (isTaskStarted) return null;
|
|
|
|
// Multi-branch support keeps repository options selectable across rows. The
|
|
// picker marks selections already made elsewhere so users can intentionally
|
|
// choose the same repository for another branch without doing so by mistake.
|
|
const hasDiscovered = fs.discoveredRepositories.length > 0;
|
|
const canAddMore = repositories.length > 0 || hasDiscovered;
|
|
const addHint = computeAddHint(canAddMore, repositories.length);
|
|
const branchPolicyDisabledReason = policyDisabled(isLocalExecutor, freshBranchAvailable);
|
|
|
|
return (
|
|
// min-h-9 reserves enough vertical space for the tallest mode body so the
|
|
// modal doesn't jump when the user toggles between Repo / URL / None
|
|
// (None renders a single pill, Repo can render chips + branch + add and
|
|
// sometimes wraps when the segmented control crowds the row).
|
|
<div
|
|
ref={chipRowRef}
|
|
className="flex min-h-9 flex-wrap items-center gap-2"
|
|
data-testid="repo-chips-row"
|
|
>
|
|
<ModeBody
|
|
fs={fs}
|
|
repositories={repositories}
|
|
workspaceId={workspaceId}
|
|
isLocalExecutor={!!isLocalExecutor}
|
|
canAddMore={canAddMore}
|
|
addHint={addHint}
|
|
freshBranchAvailable={freshBranchAvailable}
|
|
freshBranchEnabled={freshBranchEnabled}
|
|
branchPolicyDisabledReason={branchPolicyDisabledReason}
|
|
onRowRepositoryChange={onRowRepositoryChange}
|
|
onRowBranchChange={handleRowBranchChange}
|
|
onRowPolicyChange={onRowPolicyChange}
|
|
onPolicySelected={onPolicySelected}
|
|
onToggleFreshBranch={onToggleFreshBranch}
|
|
onWorkspacePathChange={onWorkspacePathChange}
|
|
lastUsedBranch={lastUsedBranch}
|
|
userSettingsLoaded={userSettingsLoaded}
|
|
onCreateRepository={localRepositoryCreation ? openForRow : undefined}
|
|
onRefreshRepositories={onRefreshRepositories}
|
|
repositoriesRefreshing={repositoriesRefreshing}
|
|
/>
|
|
{/* Sets select workspace repositories, so they are offered only in the mode
|
|
that selects those: not in Remote URL or No repository. */}
|
|
{repositorySets && !fs.useRemote && !fs.noRepository ? (
|
|
<RepositorySetsSurface
|
|
repositorySets={repositorySets}
|
|
repositories={repositories}
|
|
rows={fs.repositories}
|
|
/>
|
|
) : null}
|
|
<SourceModeSwitch
|
|
useRemote={fs.useRemote}
|
|
noRepository={fs.noRepository}
|
|
onToggleRemote={onToggleRemote}
|
|
onToggleNoRepository={onToggleNoRepository}
|
|
/>
|
|
<LocalRepositoryCreationSurface
|
|
creation={localRepositoryCreation}
|
|
target={target}
|
|
targetRef={targetRef}
|
|
workspaceId={workspaceId}
|
|
multiRow={fs.repositories.length > 1}
|
|
onOpenChange={handleCreationOpenChange}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The Sets control plus its save dialog. Extracted so RepoChipsRow stays under
|
|
* the function-length cap.
|
|
*/
|
|
function RepositorySetsSurface({
|
|
repositorySets,
|
|
repositories,
|
|
rows,
|
|
}: {
|
|
repositorySets: NonNullable<RepoChipsRowProps["repositorySets"]>;
|
|
repositories: Repository[];
|
|
rows: TaskRepoRow[];
|
|
}) {
|
|
const save = repositorySets.save;
|
|
return (
|
|
<>
|
|
<RepositorySetsControl
|
|
sets={repositorySets.sets}
|
|
repositories={repositories}
|
|
rows={rows}
|
|
onApply={repositorySets.onApply}
|
|
footerActions={
|
|
save ? <SaveRepositorySetMenuAction onSelect={() => save.setOpen(true)} /> : null
|
|
}
|
|
/>
|
|
{save ? (
|
|
<SaveRepositorySetDialog
|
|
open={save.open}
|
|
onOpenChange={save.setOpen}
|
|
workspaceId={save.workspaceId}
|
|
rows={save.rows}
|
|
repositories={save.repositories}
|
|
isLocalExecutor={save.isLocalExecutor}
|
|
freshBranchEnabled={save.freshBranchEnabled}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ModeBody({
|
|
fs,
|
|
repositories,
|
|
workspaceId,
|
|
isLocalExecutor,
|
|
canAddMore,
|
|
addHint,
|
|
freshBranchAvailable,
|
|
freshBranchEnabled,
|
|
branchPolicyDisabledReason,
|
|
onRowRepositoryChange,
|
|
onRowBranchChange,
|
|
onRowPolicyChange,
|
|
onPolicySelected,
|
|
onToggleFreshBranch,
|
|
onWorkspacePathChange,
|
|
lastUsedBranch,
|
|
userSettingsLoaded,
|
|
onCreateRepository,
|
|
onRefreshRepositories,
|
|
repositoriesRefreshing,
|
|
}: {
|
|
fs: DialogFormState;
|
|
repositories: Repository[];
|
|
workspaceId: string | null;
|
|
isLocalExecutor: boolean;
|
|
canAddMore: boolean;
|
|
addHint: string | undefined;
|
|
freshBranchAvailable?: boolean;
|
|
freshBranchEnabled?: boolean;
|
|
branchPolicyDisabledReason?: string;
|
|
onRowRepositoryChange: (key: string, value: string) => void;
|
|
onRowBranchChange: (key: string, value: string) => void;
|
|
onRowPolicyChange?: (key: string, policyId: string, baseBranch: string) => void;
|
|
onPolicySelected?: () => void;
|
|
onToggleFreshBranch?: (enabled: boolean) => void;
|
|
onWorkspacePathChange?: (value: string) => void;
|
|
lastUsedBranch?: string | null;
|
|
userSettingsLoaded?: boolean;
|
|
onCreateRepository?: (key: string) => void;
|
|
onRefreshRepositories?: () => void;
|
|
repositoriesRefreshing?: boolean;
|
|
}) {
|
|
if (fs.noRepository) {
|
|
return <NoRepositoryMode fs={fs} onWorkspacePathChange={onWorkspacePathChange} />;
|
|
}
|
|
if (fs.useRemote) {
|
|
return (
|
|
<RemoteRepoChipsRow
|
|
workspaceId={workspaceId}
|
|
fs={fs}
|
|
onUpdateRow={fs.updateRemoteRepo}
|
|
onAddRow={fs.addRemoteRepo}
|
|
onRemoveRow={fs.removeRemoteRepo}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<WorkspaceRepoChips
|
|
rows={fs.repositories}
|
|
repositories={repositories}
|
|
discoveredRepositories={fs.discoveredRepositories}
|
|
workspaceId={workspaceId}
|
|
branchLocked={false}
|
|
isLocalExecutor={isLocalExecutor}
|
|
currentLocalBranch={fs.currentLocalBranch}
|
|
currentLocalBranchLoading={fs.currentLocalBranchLoading}
|
|
freshBranchEnabled={fs.freshBranchEnabled}
|
|
branchPolicyDisabledReason={branchPolicyDisabledReason}
|
|
canAddMore={canAddMore}
|
|
addHint={addHint}
|
|
onAdd={fs.addRepository}
|
|
onRemove={fs.removeRepository}
|
|
onRowRepositoryChange={onRowRepositoryChange}
|
|
onRowBranchChange={onRowBranchChange}
|
|
onRowBaseBranchChange={(key, value) => updateSavedBaseBranch(fs, key, value)}
|
|
onRowPolicyChange={onRowPolicyChange}
|
|
onPolicySelected={onPolicySelected}
|
|
showBranchPolicies
|
|
showDiscoveryControls
|
|
lastUsedBranch={lastUsedBranch}
|
|
userSettingsLoaded={userSettingsLoaded}
|
|
onCreateRepository={onCreateRepository}
|
|
onRefreshRepositories={onRefreshRepositories}
|
|
repositoriesRefreshing={repositoriesRefreshing}
|
|
freshBranchToggle={buildFreshBranchToggle(
|
|
fs.repositories.length,
|
|
freshBranchAvailable,
|
|
freshBranchEnabled,
|
|
onToggleFreshBranch,
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function NoRepositoryMode({
|
|
fs,
|
|
onWorkspacePathChange,
|
|
}: {
|
|
fs: DialogFormState;
|
|
onWorkspacePathChange?: (value: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<FolderPicker
|
|
value={fs.workspacePath}
|
|
onChange={onWorkspacePathChange ?? (() => {})}
|
|
placeholder={t("task:pickAStartingFolderOptional")}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function buildFreshBranchToggle(
|
|
repositoryCount: number,
|
|
available: boolean | undefined,
|
|
enabled: boolean | undefined,
|
|
onToggle?: (enabled: boolean) => void,
|
|
) {
|
|
if (!available || !onToggle || repositoryCount !== 1) return null;
|
|
return <FreshBranchToggle enabled={!!enabled} onToggle={onToggle} />;
|
|
}
|
|
|
|
function FreshBranchToggle({
|
|
enabled,
|
|
onToggle,
|
|
}: {
|
|
enabled: boolean;
|
|
onToggle: (enabled: boolean) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => onToggle(!enabled)}
|
|
data-testid="fresh-branch-toggle"
|
|
aria-pressed={enabled}
|
|
aria-label={enabled ? t("task:forkANewBranchFromA") : t("task:forkANewBranchFromA2")}
|
|
className={cn(
|
|
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-input cursor-pointer transition-colors",
|
|
enabled
|
|
? "bg-muted text-foreground"
|
|
: "bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/60",
|
|
)}
|
|
>
|
|
<IconGitFork className="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="max-w-xs">
|
|
{enabled ? t("task:forkModeANewBranchWill") : t("task:byDefaultTheLocalExecutorUses")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function computeAddHint(canAddMore: boolean, workspaceRepoCount: number): string | undefined {
|
|
if (canAddMore) return undefined;
|
|
if (workspaceRepoCount === 0) return t("task:noRepositoriesAvailableInWorkspace");
|
|
return t("task:allWorkspaceRepositoriesAdded");
|
|
}
|
|
|
|
function policyDisabled(
|
|
isLocalExecutor: boolean | undefined,
|
|
freshBranchAvailable: boolean | undefined,
|
|
): string | undefined {
|
|
return isLocalExecutor && !freshBranchAvailable
|
|
? t("task:branchPolicyRequiresSingleRepository")
|
|
: undefined;
|
|
}
|