9e8079de69
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.
317 lines
8.8 KiB
TypeScript
317 lines
8.8 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
IconArrowBackUp,
|
|
IconPlus,
|
|
IconMinus,
|
|
IconCheck,
|
|
IconLoader2,
|
|
IconPencil,
|
|
} from "@tabler/icons-react";
|
|
|
|
import { Button } from "@kandev/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import { LineStat } from "@/components/diff-stat";
|
|
import type { FileInfo } from "@/lib/state/store";
|
|
import { FileStatusIcon } from "./file-status-icon";
|
|
|
|
const splitPath = (path: string) => {
|
|
const lastSlash = path.lastIndexOf("/");
|
|
if (lastSlash === -1) return { folder: "", file: path };
|
|
return {
|
|
folder: path.slice(0, lastSlash),
|
|
file: path.slice(lastSlash + 1),
|
|
};
|
|
};
|
|
|
|
type ChangedFile = {
|
|
path: string;
|
|
status: FileInfo["status"];
|
|
staged: boolean;
|
|
plus: number | undefined;
|
|
minus: number | undefined;
|
|
oldPath: string | undefined;
|
|
/** Multi-repo: name of the repo this file lives in. Empty for single-repo. */
|
|
repositoryName?: string;
|
|
};
|
|
|
|
type FileRowProps = {
|
|
file: ChangedFile;
|
|
isPending: boolean;
|
|
isSelected?: boolean;
|
|
onSelect?: (path: string, e: React.MouseEvent) => boolean;
|
|
onOpenDiff: (path: string) => void;
|
|
// Multi-repo: handlers receive the file's repository_name so the per-file
|
|
// staging op routes to the right git repo. Same-named files (e.g. README.md
|
|
// in two repos) cannot be disambiguated by path alone.
|
|
onStage: (path: string, repo?: string) => void;
|
|
onUnstage: (path: string, repo?: string) => void;
|
|
onDiscard: (path: string, repo?: string) => void;
|
|
onEditFile: (path: string) => void;
|
|
};
|
|
|
|
export function FileRow({
|
|
file,
|
|
isPending,
|
|
isSelected,
|
|
onSelect,
|
|
onOpenDiff,
|
|
onStage,
|
|
onUnstage,
|
|
onDiscard,
|
|
onEditFile,
|
|
}: FileRowProps) {
|
|
const { folder, file: name } = splitPath(file.path);
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
if (e.button === 2) return;
|
|
const consumed = onSelect?.(file.path, e);
|
|
if (!consumed) {
|
|
onOpenDiff(file.path);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<li
|
|
data-testid={`file-row-${file.path.replace(/[/\\]/g, "-")}`}
|
|
data-changes-file={file.path}
|
|
data-selected={isSelected ? "true" : "false"}
|
|
className={cn(
|
|
"group flex items-center justify-between gap-2 text-sm rounded-md px-1 py-0.5 -mx-1 cursor-pointer",
|
|
isSelected ? "bg-accent/60 text-accent-foreground hover:bg-accent/50" : "hover:bg-muted/60",
|
|
)}
|
|
onClick={handleClick}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<StageButton
|
|
isPending={isPending}
|
|
staged={file.staged}
|
|
path={file.path}
|
|
repo={file.repositoryName}
|
|
onStage={onStage}
|
|
onUnstage={onUnstage}
|
|
/>
|
|
<button type="button" className="min-w-0 text-left cursor-pointer" title={file.path}>
|
|
<p className="flex text-foreground text-xs min-w-0">
|
|
{folder && <span className="text-foreground/60 truncate shrink">{folder}/</span>}
|
|
<span className="font-medium text-foreground whitespace-nowrap shrink-0">{name}</span>
|
|
</p>
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<FileRowActions
|
|
path={file.path}
|
|
repo={file.repositoryName}
|
|
onDiscard={onDiscard}
|
|
onEditFile={onEditFile}
|
|
/>
|
|
<LineStat added={file.plus} removed={file.minus} />
|
|
<FileStatusIcon status={file.status} />
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function StageButton({
|
|
isPending,
|
|
staged,
|
|
path,
|
|
repo,
|
|
onStage,
|
|
onUnstage,
|
|
}: {
|
|
isPending: boolean;
|
|
staged: boolean;
|
|
path: string;
|
|
repo?: string;
|
|
onStage: (path: string, repo?: string) => void;
|
|
onUnstage: (path: string, repo?: string) => void;
|
|
}) {
|
|
if (isPending) {
|
|
return (
|
|
<div className="flex-shrink-0 flex items-center justify-center size-4">
|
|
<IconLoader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
if (staged) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
title="Unstage file"
|
|
className="group/unstage flex-shrink-0 flex items-center justify-center size-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onUnstage(path, repo);
|
|
}}
|
|
>
|
|
<IconCheck className="h-3 w-3 group-hover/unstage:hidden" />
|
|
<IconMinus className="h-2.5 w-2.5 hidden group-hover/unstage:block" />
|
|
</button>
|
|
);
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
title="Stage file"
|
|
className="flex-shrink-0 flex items-center justify-center size-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onStage(path, repo);
|
|
}}
|
|
>
|
|
<IconPlus className="h-2.5 w-2.5" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function FileRowActions({
|
|
path,
|
|
repo,
|
|
onDiscard,
|
|
onEditFile,
|
|
}: {
|
|
path: string;
|
|
repo?: string;
|
|
onDiscard: (path: string, repo?: string) => void;
|
|
onEditFile: (path: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDiscard(path, repo);
|
|
}}
|
|
>
|
|
<IconArrowBackUp className="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Discard changes</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onEditFile(path);
|
|
}}
|
|
>
|
|
<IconPencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Edit</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Bulk action components (used by FileListSection) ---
|
|
|
|
export function DefaultActionButtons({
|
|
actionLabel,
|
|
isActionLoading,
|
|
onAction,
|
|
secondaryActionLabel,
|
|
isSecondaryActionLoading,
|
|
onSecondaryAction,
|
|
}: {
|
|
actionLabel: string;
|
|
isActionLoading?: boolean;
|
|
onAction: () => void;
|
|
secondaryActionLabel?: string;
|
|
isSecondaryActionLoading?: boolean;
|
|
onSecondaryAction?: () => void;
|
|
}) {
|
|
return (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
|
|
onClick={onAction}
|
|
disabled={isActionLoading}
|
|
>
|
|
{isActionLoading && <IconLoader2 className="h-3 w-3 animate-spin" />}
|
|
{actionLabel}
|
|
</Button>
|
|
{onSecondaryAction && secondaryActionLabel && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
|
|
onClick={onSecondaryAction}
|
|
disabled={isSecondaryActionLoading}
|
|
>
|
|
{isSecondaryActionLoading && <IconLoader2 className="h-3 w-3 animate-spin" />}
|
|
{secondaryActionLabel}
|
|
</Button>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function BulkActionBar({
|
|
variant,
|
|
selectionCount,
|
|
selectedPaths,
|
|
onBulkStage,
|
|
onBulkUnstage,
|
|
onBulkDiscard,
|
|
}: {
|
|
variant: "unstaged" | "staged";
|
|
selectionCount: number;
|
|
selectedPaths: Set<string>;
|
|
onBulkStage?: (paths: string[]) => void;
|
|
onBulkUnstage?: (paths: string[]) => void;
|
|
onBulkDiscard?: (paths: string[]) => void;
|
|
}) {
|
|
const paths = [...selectedPaths];
|
|
|
|
return (
|
|
<div data-testid={`bulk-actions-${variant}`} className="flex items-center gap-1.5">
|
|
<span className="text-[11px] text-muted-foreground">{selectionCount} selected</span>
|
|
{variant === "unstaged" && onBulkStage && (
|
|
<Button
|
|
data-testid="bulk-stage"
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
|
|
onClick={() => onBulkStage(paths)}
|
|
>
|
|
Stage {selectionCount}
|
|
</Button>
|
|
)}
|
|
{variant === "staged" && onBulkUnstage && (
|
|
<Button
|
|
data-testid={`bulk-unstage-${variant}`}
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
|
|
onClick={() => onBulkUnstage(paths)}
|
|
>
|
|
Unstage {selectionCount}
|
|
</Button>
|
|
)}
|
|
{onBulkDiscard && (
|
|
<Button
|
|
data-testid={`bulk-discard-${variant}`}
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer text-destructive hover:text-destructive"
|
|
onClick={() => onBulkDiscard(paths)}
|
|
>
|
|
Discard {selectionCount}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|