161 lines
5.6 KiB
TypeScript
161 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { IconChevronDown, IconChevronRight, IconGitPullRequest } from "@tabler/icons-react";
|
|
import { LineStat } from "@/components/diff-stat";
|
|
import { FileStatusIcon } from "@/components/shared/file-status-icon";
|
|
import { cn } from "@/lib/utils";
|
|
import { useDockviewStore } from "@/lib/state/dockview-store";
|
|
import { groupByRepositoryName } from "@/lib/group-by-repo";
|
|
import type { PRChangedFile } from "./changes-panel-timeline";
|
|
import type { OpenDiffOptions } from "./changes-diff-target";
|
|
|
|
type PRFilesSectionContentProps = {
|
|
files: PRChangedFile[];
|
|
onOpenDiff: (path: string, options?: OpenDiffOptions) => void;
|
|
/** Maps a repository_name to a human-readable label for the per-repo header. */
|
|
repoDisplayName?: (repositoryName: string) => string | undefined;
|
|
};
|
|
|
|
/**
|
|
* Body of the PR Changes section: groups files by `repository_name` so
|
|
* multi-repo tasks render one sub-header per repo, mirroring the Commits
|
|
* and Changes sections. Single-repo tasks (one group with empty name)
|
|
* render the file list flat — the section header is enough.
|
|
*/
|
|
export function PRFilesGroupedList({
|
|
files,
|
|
onOpenDiff,
|
|
repoDisplayName,
|
|
}: PRFilesSectionContentProps) {
|
|
const activeFilePath = useDockviewStore((s) => s.activeFilePath);
|
|
const groups = groupByRepositoryName(files, (f) => f.repository_name);
|
|
const showRepoHeaders = groups.length > 1 || (groups[0]?.repositoryName ?? "") !== "";
|
|
return (
|
|
<ul className="space-y-0.5" data-testid="pr-files-list">
|
|
{groups.map((group) => (
|
|
<PRFilesRepoGroup
|
|
key={group.repositoryName || "__no_repo__"}
|
|
repositoryName={group.repositoryName}
|
|
displayName={repoDisplayName?.(group.repositoryName)}
|
|
files={group.items}
|
|
showHeader={showRepoHeaders}
|
|
onOpenDiff={onOpenDiff}
|
|
activeFilePath={activeFilePath}
|
|
/>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
function PRFilesRepoGroup({
|
|
repositoryName,
|
|
displayName,
|
|
files,
|
|
showHeader,
|
|
onOpenDiff,
|
|
activeFilePath,
|
|
}: {
|
|
repositoryName: string;
|
|
displayName?: string;
|
|
files: PRChangedFile[];
|
|
showHeader: boolean;
|
|
onOpenDiff: (path: string, options?: OpenDiffOptions) => void;
|
|
activeFilePath: string | null;
|
|
}) {
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const label = displayName ?? repositoryName ?? "";
|
|
return (
|
|
<li data-testid="pr-files-repo-group" data-repository-name={repositoryName || ""}>
|
|
{showHeader && (
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1.5 px-1 py-0.5 text-[11px] font-medium text-muted-foreground/80 uppercase tracking-wide cursor-pointer hover:text-foreground/80 min-w-0"
|
|
data-testid="pr-files-repo-header"
|
|
aria-expanded={!collapsed}
|
|
onClick={() => setCollapsed((c) => !c)}
|
|
>
|
|
{collapsed ? (
|
|
<IconChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
|
) : (
|
|
<IconChevronDown className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
|
)}
|
|
<span className="truncate">{label}</span>
|
|
<span className="text-muted-foreground/50 normal-case tracking-normal">
|
|
{files.length}
|
|
</span>
|
|
</button>
|
|
)}
|
|
{!collapsed && (
|
|
<ul className="space-y-0.5">
|
|
{/* Multi-repo: activeFilePath carries no repo context, so identical
|
|
paths across repos light up both rows. Matches FileListBody. */}
|
|
{files.map((file) => (
|
|
<PRFileRow
|
|
key={file.path}
|
|
file={file}
|
|
onOpenDiff={onOpenDiff}
|
|
isActive={file.path === activeFilePath}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function PRFileRow({
|
|
file,
|
|
onOpenDiff,
|
|
isActive,
|
|
}: {
|
|
file: PRChangedFile;
|
|
onOpenDiff: (path: string, options?: OpenDiffOptions) => void;
|
|
isActive?: boolean;
|
|
}) {
|
|
const lastSlash = file.path.lastIndexOf("/");
|
|
const folder = lastSlash === -1 ? "" : file.path.slice(0, lastSlash);
|
|
const name = lastSlash === -1 ? file.path : file.path.slice(lastSlash + 1);
|
|
|
|
return (
|
|
<li
|
|
data-changes-file={file.path}
|
|
data-pr-key={file.prKey}
|
|
data-active={isActive ? "true" : "false"}
|
|
className={cn(
|
|
"group flex items-center justify-between gap-2 rounded-md border border-transparent px-2 py-1.5 -mx-1 text-sm cursor-pointer md:px-1 md:py-0.5",
|
|
isActive
|
|
? "border-primary/50 bg-card text-foreground hover:bg-muted/70"
|
|
: "hover:bg-muted/60",
|
|
)}
|
|
onClick={() =>
|
|
onOpenDiff(file.path, {
|
|
source: "pr",
|
|
repositoryName: file.repository_name || undefined,
|
|
prKey: file.prKey,
|
|
})
|
|
}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<div className="flex-shrink-0 flex items-center justify-center size-4">
|
|
<IconGitPullRequest className="h-3 w-3 text-purple-500" />
|
|
</div>
|
|
<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 min-w-0 [flex-shrink:9999]">
|
|
{folder}/
|
|
</span>
|
|
)}
|
|
<span className="font-medium text-foreground truncate min-w-0">{name}</span>
|
|
</p>
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<LineStat added={file.plus} removed={file.minus} />
|
|
<FileStatusIcon status={file.status} oldPath={file.oldPath} />
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|