Files
kandev/apps/web/components/task-create-dialog-workspace-repo-chips.tsx
T
2026-09-12 16:29:16 +01:00

581 lines
19 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { IconX } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useBranches, type BranchSource } from "@/hooks/domains/workspace/use-repository-branches";
import type { LocalRepository, Repository, RepositoryBranchPolicy } from "@/lib/types/http";
import type { TaskRepoRow } from "@/components/task-create-dialog-types";
import { type PillOption } from "@/components/task-create-dialog-pill";
import { branchToOption, sortBranches } from "@/components/branch-picker-options";
import { buildRepoBaseBranchData } from "@/components/task-create-dialog-repo-base-branch";
import {
buildRepoOptions,
computeRepoChipDisplay,
normalizeRepoPath,
} from "@/components/task-create-dialog-repo-chip-utils";
import {
computeBranchIntent,
type BranchIntent,
} from "@/components/task-create-dialog-branch-utils";
import { useRepoBranchAutoselect } from "@/components/task-create-dialog-repo-branch-autoselect";
import { useRepositoryBranchPolicies } from "@/hooks/domains/workspace/use-repository-branch-policies";
import {
RepoChipBaseBranchPill,
RepoChipBranchPill,
RepoChipRepositoryPill,
useRepoChipBranchPicker,
} from "@/components/task-create-dialog-repo-chip-parts";
import { AddRepositoryButton } from "@/components/task-create-dialog-add-repository-button";
import { useTranslation } from "react-i18next";
import { RepositoryDiscoveryControls } from "@/components/repository-discovery-controls";
type WorkspaceRepoChipsProps = {
rows: TaskRepoRow[];
repositories: Repository[];
discoveredRepositories?: LocalRepository[];
workspaceId: string | null;
branchLocked?: boolean;
isLocalExecutor?: boolean;
currentLocalBranch?: string;
currentLocalBranchLoading?: boolean;
freshBranchEnabled?: boolean;
branchPolicyDisabledReason?: string;
showBranchPolicies?: boolean;
showDiscoveryControls?: boolean;
canAddMore: boolean;
addHint?: string;
addLabel?: string;
allowDuplicateRepositories?: boolean;
freshBranchToggle?: React.ReactNode;
onAdd: () => void;
onRemove: (key: string) => void;
onRowRepositoryChange: (key: string, value: string) => void;
onRowBranchChange: (key: string, value: string) => void;
onRowBaseBranchChange?: (key: string, value: string) => void;
onRowPolicyChange?: (key: string, policyId: string, baseBranch: string) => void;
onPolicySelected?: () => void;
onCreateRepository?: (key: string) => void;
onRefreshRepositories?: () => void;
repositoriesRefreshing?: boolean;
lastUsedBranch?: string | null;
userSettingsLoaded?: boolean;
};
/**
* Renders the list of repo chips plus the trailing "+ add repository"
* button. Extracted from RepoChipsRow so the parent stays under the
* function-length cap; logic is unchanged.
*/
export function WorkspaceRepoChips({
rows,
repositories,
discoveredRepositories,
workspaceId,
branchLocked,
isLocalExecutor,
currentLocalBranch,
currentLocalBranchLoading,
freshBranchEnabled,
branchPolicyDisabledReason,
showBranchPolicies = false,
showDiscoveryControls = false,
canAddMore,
addHint,
addLabel,
allowDuplicateRepositories = true,
freshBranchToggle,
onAdd,
onRemove,
onRowRepositoryChange,
onRowBranchChange,
onRowBaseBranchChange,
onRowPolicyChange,
onPolicySelected,
onCreateRepository,
onRefreshRepositories,
repositoriesRefreshing,
lastUsedBranch,
userSettingsLoaded,
}: WorkspaceRepoChipsProps) {
return (
<>
{rows.map((row) => (
<RepoChip
key={row.key}
row={row}
workspaceId={workspaceId}
repositories={repositories}
discoveredRepositories={discoveredRepositories ?? []}
// Task creation marks other rows' picks but keeps every option
// selectable; quick chat excludes a repository once another row uses it.
excludedRepoIds={collectExcludedRepoIds(rows, row, allowDuplicateRepositories)}
selectedElsewhere={collectSelectedRepoIdentities(rows, row)}
branchLocked={branchLocked}
// For local-executor rows, seed row.branch with the workspace's
// current branch via this prop. Non-local rows leave it undefined
// and fall back to the existing last-used / preferred-default
// autoselect path.
preferredDefaultBranch={isLocalExecutor ? currentLocalBranch : undefined}
preferredDefaultBranchLoading={isLocalExecutor ? currentLocalBranchLoading : false}
lastUsedBranch={lastUsedBranch}
userSettingsLoaded={userSettingsLoaded}
isLocalExecutor={!!isLocalExecutor}
branchValue={isLocalExecutor ? row.branch : row.baseBranch || row.branch}
savedBaseBranch={row.baseBranch}
branchIntent={computeBranchIntent({
isLocalExecutor: !!isLocalExecutor,
rowBranch: isLocalExecutor ? row.branch : row.baseBranch || row.branch,
currentLocalBranch: currentLocalBranch ?? "",
freshBranchEnabled: !!freshBranchEnabled,
})}
branchPolicyDisabledReason={branchPolicyDisabledReason}
onRepositoryChange={(value) => onRowRepositoryChange(row.key, value)}
onBranchChange={(value) => onRowBranchChange(row.key, value)}
onBaseBranchChange={(value) => onRowBaseBranchChange?.(row.key, value)}
onPolicyChange={
onRowPolicyChange
? (policyId, baseBranch) => onRowPolicyChange(row.key, policyId, baseBranch)
: undefined
}
onPolicySelected={onPolicySelected}
showBranchPolicies={showBranchPolicies}
showDiscoveryControls={showDiscoveryControls}
onCreateRepository={onCreateRepository ? () => onCreateRepository(row.key) : undefined}
onRefreshRepositories={onRefreshRepositories}
repositoriesRefreshing={repositoriesRefreshing}
onRemove={() => onRemove(row.key)}
/>
))}
{freshBranchToggle}
<AddRepositoryButton
canAddMore={canAddMore}
addHint={addHint}
addLabel={addLabel}
onAdd={onAdd}
/>
</>
);
}
/**
* Returns the repo ids/paths that should be hidden from `currentRow` based on
* the caller's repository-duplication mode.
*
* When duplicates are allowed, task creation keeps every repository available
* so a user can intentionally pick the same repository for another branch.
* When duplicates are disabled, quick chat excludes the entire repository
* after another row selects it, regardless of branch.
*
* Same-row entries are skipped so the current row's own pick remains
* selectable; without that, after the user pairs (repo, branch) the chip
* would suddenly render its current repo as unavailable.
*/
function collectExcludedRepoIds(
rows: TaskRepoRow[],
currentRow: TaskRepoRow,
allowDuplicateRepositories: boolean,
): Set<string> {
if (allowDuplicateRepositories) return new Set();
const ids = new Set<string>();
for (const r of rows) {
if (r.key === currentRow.key) continue;
if (r.repositoryId) ids.add(r.repositoryId);
if (r.localPath) ids.add(r.localPath);
}
return ids;
}
function collectSelectedRepoIdentities(rows: TaskRepoRow[], currentRow: TaskRepoRow): Set<string> {
const identities = new Set<string>();
for (const row of rows) {
if (row.key === currentRow.key) continue;
if (row.repositoryId) identities.add(repoIdIdentity(row.repositoryId));
if (row.localPath) identities.add(repoPathIdentity(row.localPath));
}
return identities;
}
function repoIdIdentity(id: string): string {
return `id:${id}`;
}
function repoPathIdentity(path: string): string {
return `path:${normalizeRepoPath(path)}`;
}
type RepoChipProps = {
row: TaskRepoRow;
/** Required for path-based branch loading on discovered rows. */
workspaceId: string | null;
repositories: Repository[];
discoveredRepositories: LocalRepository[];
/** Repo IDs/paths to filter out of the dropdown (already in use elsewhere). */
excludedRepoIds: Set<string>;
/** Repository identities selected in another row, rendered as a marker. */
selectedElsewhere: Set<string>;
/**
* Lock the branch pill regardless of branch availability. Used for the
* local executor where the user's actual checkout dictates the branch
* (and changing it would mutate their working tree). Fresh-branch mode
* unlocks it because we're explicitly creating a new branch from a base.
*/
branchLocked?: boolean;
/**
* When set, seed row.branch with this value (for an empty row). Used by
* the local-executor flow to surface the workspace's current ref — either
* a branch name like "main" or, on detached HEAD, the short commit SHA
* returned by the backend. The chip displays it verbatim ("current: main"
* or "current: 4fbc5d7"); on submit the backend's skip-when-equal check
* matches the same SHA so it's a no-op.
*
* When unset, the chip falls back to the existing last-used / preferred-
* default autoselect (main / master / develop, etc.).
*/
preferredDefaultBranch?: string;
lastUsedBranch?: string | null;
userSettingsLoaded?: boolean;
isLocalExecutor?: boolean;
branchValue?: string;
savedBaseBranch?: string;
/**
* True while preferredDefaultBranch is being resolved. Renders a
* "Loading branch…" placeholder so the chip doesn't briefly show an empty
* state in the window between dialog open and local-status resolving.
*/
preferredDefaultBranchLoading?: boolean;
/**
* Muted text shown before the branch value to qualify intent:
* - "current: " — local exec, picked branch == workspace current
* - "will switch to: " — local exec, picked branch != workspace current
* - "from: " — worktree / non-local exec (picked branch is the base)
* Empty when there's no branch value yet (chip shows the "branch"
* placeholder unprefixed).
*/
branchIntent?: BranchIntent;
onRepositoryChange: (value: string) => void;
onBranchChange: (value: string) => void;
onBaseBranchChange?: (value: string) => void;
onPolicyChange?: (policyId: string, baseBranch: string) => void;
onPolicySelected?: () => void;
branchPolicyDisabledReason?: string;
showBranchPolicies?: boolean;
showDiscoveryControls?: boolean;
onRemove: () => void;
onCreateRepository?: () => void;
onRefreshRepositories?: () => void;
repositoriesRefreshing?: boolean;
};
function useRepoChipBranchData({
row,
workspaceId,
onBranchChange,
branchValue,
preferredDefaultBranch,
preferredDefaultBranchLoading,
lastUsedBranch,
userSettingsLoaded,
}: Pick<
RepoChipProps,
| "row"
| "workspaceId"
| "onBranchChange"
| "branchValue"
| "preferredDefaultBranch"
| "preferredDefaultBranchLoading"
| "lastUsedBranch"
| "userSettingsLoaded"
| "savedBaseBranch"
| "isLocalExecutor"
>) {
const branchSource = useMemo<BranchSource | null>(() => {
if (!workspaceId) return null;
if (row.repositoryId) {
return { kind: "id", workspaceId, repositoryId: row.repositoryId };
}
if (row.localPath) {
return { kind: "path", workspaceId, path: row.localPath };
}
return null;
}, [workspaceId, row.repositoryId, row.localPath]);
const {
branches,
isLoading: branchesLoading,
refresh: refreshBranches,
isLoaded: branchesLoaded,
} = useBranches(branchSource, !!branchSource);
useRepoBranchAutoselect({
branchSource,
branchesLoading,
branches,
rowBranch: branchValue,
onBranchChange,
preferredDefaultBranch,
preferredDefaultBranchLoading,
lastUsedBranch,
userSettingsLoaded,
});
return { branches, branchesLoading, branchesLoaded, refreshBranches };
}
function useRepoChipData({
row,
workspaceId,
repositories,
discoveredRepositories,
excludedRepoIds,
selectedElsewhere,
onBranchChange,
preferredDefaultBranch,
preferredDefaultBranchLoading,
lastUsedBranch,
userSettingsLoaded,
branchValue,
savedBaseBranch,
isLocalExecutor,
}: Pick<
RepoChipProps,
| "row"
| "workspaceId"
| "repositories"
| "discoveredRepositories"
| "excludedRepoIds"
| "selectedElsewhere"
| "onBranchChange"
| "preferredDefaultBranch"
| "preferredDefaultBranchLoading"
| "lastUsedBranch"
| "userSettingsLoaded"
| "branchValue"
| "savedBaseBranch"
| "isLocalExecutor"
>) {
const filteredRepos = useMemo(
() => repositories.filter((r) => !excludedRepoIds.has(r.id) || r.id === row.repositoryId),
[repositories, excludedRepoIds, row.repositoryId],
);
const filteredDiscovered = useMemo(() => {
const workspaceRepoPaths = new Set(
filteredRepos
.map((r) => r.local_path)
.filter(Boolean)
.map((path: string) => normalizeRepoPath(path)),
);
return discoveredRepositories.filter(
(r) =>
!workspaceRepoPaths.has(normalizeRepoPath(r.path)) &&
(!excludedRepoIds.has(r.path) || r.path === row.localPath),
);
}, [filteredRepos, discoveredRepositories, excludedRepoIds, row.localPath]);
const { branches, branchesLoading, branchesLoaded, refreshBranches } = useRepoChipBranchData({
row,
workspaceId,
onBranchChange,
branchValue,
preferredDefaultBranch,
preferredDefaultBranchLoading,
lastUsedBranch,
userSettingsLoaded,
});
const repoOptions: PillOption[] = useMemo(
() => buildRepoOptions(filteredRepos, filteredDiscovered, selectedElsewhere),
[filteredRepos, filteredDiscovered, selectedElsewhere],
);
const { baseBranchOptions, defaultBranch } = useMemo(
() =>
buildRepoBaseBranchData({
branches,
branchesLoaded,
savedBaseBranch,
row,
repositories,
discoveredRepositories,
}),
[branches, branchesLoaded, discoveredRepositories, repositories, row, savedBaseBranch],
);
const branchOptions: PillOption[] = useMemo(() => {
if (!isLocalExecutor && savedBaseBranch) return baseBranchOptions;
return sortBranches(branches).map(branchToOption);
}, [baseBranchOptions, branches, isLocalExecutor, savedBaseBranch]);
return {
repoOptions,
branchOptions,
baseBranchOptions,
defaultBranch,
branches,
branchesLoading,
refreshBranches,
};
}
type RepoChipData = ReturnType<typeof useRepoChipData>;
function RepoChip(props: RepoChipProps) {
const {
row,
workspaceId,
repositories,
discoveredRepositories,
excludedRepoIds,
selectedElsewhere,
onBranchChange,
showBranchPolicies,
preferredDefaultBranch,
preferredDefaultBranchLoading,
lastUsedBranch,
userSettingsLoaded,
isLocalExecutor,
branchValue,
savedBaseBranch,
} = props;
const data = useRepoChipData({
row,
workspaceId,
repositories,
discoveredRepositories,
excludedRepoIds,
selectedElsewhere,
onBranchChange,
isLocalExecutor,
branchValue: branchValue ?? row.branch,
savedBaseBranch,
preferredDefaultBranch,
preferredDefaultBranchLoading,
lastUsedBranch,
userSettingsLoaded,
});
if (showBranchPolicies) {
return <RepoChipWithPolicies {...props} data={data} />;
}
return <RepoChipContent {...props} data={data} branchPolicies={[]} />;
}
function RepoChipWithPolicies({ data, ...props }: RepoChipProps & { data: RepoChipData }) {
const { row } = props;
const { policies: branchPolicies } = useRepositoryBranchPolicies(
row.repositoryId ?? null,
!!row.repositoryId,
);
return <RepoChipContent {...props} data={data} branchPolicies={branchPolicies} />;
}
function RepoChipContent({
data,
branchPolicies,
row,
repositories,
discoveredRepositories,
branchLocked,
branchValue,
isLocalExecutor,
savedBaseBranch,
preferredDefaultBranchLoading,
branchIntent,
onRepositoryChange,
onBranchChange,
onBaseBranchChange,
onPolicyChange,
onPolicySelected,
branchPolicyDisabledReason,
onRemove,
onCreateRepository,
onRefreshRepositories,
repositoriesRefreshing,
showDiscoveryControls,
workspaceId,
}: RepoChipProps & { data: RepoChipData; branchPolicies: RepositoryBranchPolicy[] }) {
const {
repoOptions,
branchOptions,
baseBranchOptions,
defaultBranch,
branches,
branchesLoading,
refreshBranches,
} = data;
const { repoLabel, repoTooltip } = computeRepoChipDisplay(
row,
repositories,
discoveredRepositories,
);
const branchPicker = useRepoChipBranchPicker({
row,
branchPolicies,
branches,
branchOptions,
branchesLoading,
branchValue: branchValue ?? row.branch,
preferredDefaultBranchLoading,
policyDisabledReason: branchPolicyDisabledReason,
onBranchChange,
onPolicyChange,
onPolicySelected,
});
return (
<span
className="inline-flex items-center rounded-md border border-input bg-input/20 dark:bg-input/30 pr-0.5"
data-testid="repo-chip"
data-repository-id={row.repositoryId || row.localPath || ""}
data-repo-row-key={row.key}
>
<RepoChipRepositoryPill
repoLabel={repoLabel}
repoTooltip={repoTooltip}
repositoryValue={row.repositoryId || row.localPath || ""}
repoOptions={repoOptions}
onRepositoryChange={onRepositoryChange}
onCreateRepository={onCreateRepository}
onRefreshRepositories={onRefreshRepositories}
repositoriesRefreshing={repositoriesRefreshing}
popoverHeader={
showDiscoveryControls ? (
<RepositoryDiscoveryControls workspaceId={workspaceId} presentation="picker" />
) : undefined
}
/>
<RepoChipBranchPill
branchPicker={branchPicker}
branchIntent={branchIntent}
branchLocked={branchLocked}
branchesLoading={branchesLoading}
refreshBranches={refreshBranches}
/>
{isLocalExecutor && savedBaseBranch ? (
<RepoChipBaseBranchPill
options={baseBranchOptions}
value={savedBaseBranch}
defaultBranch={defaultBranch}
hasRepo={!!(row.repositoryId || row.localPath)}
branchesLoading={branchesLoading}
onSelect={onBaseBranchChange ?? (() => undefined)}
refreshBranches={refreshBranches}
/>
) : null}
<RepoChipRemoveButton onRemove={onRemove} />
</span>
);
}
function RepoChipRemoveButton({ onRemove }: { onRemove: () => void }) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onRemove}
aria-label={t("task:removeRepository")}
className="h-6 w-6 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted/60 cursor-pointer"
data-testid="remove-repo-chip"
>
<IconX className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent>{t("task:removeRepository")}</TooltipContent>
</Tooltip>
);
}