feat(web/changes-panel): per-repo group headers in the dockview Changes tab
The Changes tab in the dockview panel (the Files / Changes (N) tab the user actually interacts with — not the standalone Review dialog) was still rendering a flat file list, so multi-repo task changes either collided onto the legacy single-status (showing only one repo's files) or rendered without grouping. - FileInfo gains repository_name (optional, single-repo unchanged). - useSessionGit aggregates files from useSessionGitStatusByRepo when multi-repo data is present, stamping each FileInfo with its repository_name. Falls back to the legacy single status for single-repo workspaces. Aggregation extracted into aggregateFilesAcrossRepos to keep useSessionGit under the 100-line function lint cap. - ChangedFile (changes-panel.tsx + changes-panel-timeline.tsx) carries repositoryName through to the renderer. - FileListSection delegates to a new FileListBody that groups rows by repositoryName; renders a small per-repo subheader when 2+ repos OR any single named repo is present, otherwise falls back to the pre-multi-repo flat list (same DOM shape). - Tests cover: no header for plain (no repo_name) files, headers per repo for 2+ groups, single named repo still gets a label. Restart the backend so the per-repo trackers spawn for in-flight multi-repo tasks; new launches are unaffected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
vi.mock("./changes-panel-file-row", () => ({
|
||||
FileRow: ({ file }: { file: { path: string } }) => (
|
||||
<li data-testid="file-row">{file.path}</li>
|
||||
),
|
||||
BulkActionBar: () => null,
|
||||
DefaultActionButtons: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./commit-row", () => ({
|
||||
CommitRow: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-multi-select", () => ({
|
||||
useMultiSelect: () => ({
|
||||
selectedPaths: new Set<string>(),
|
||||
isSelected: () => false,
|
||||
handleClick: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { FileListSection } from "./changes-panel-timeline";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type Props = ComponentProps<typeof FileListSection>;
|
||||
|
||||
const baseProps: Omit<Props, "files" | "variant" | "isLast" | "actionLabel" | "onAction"> = {
|
||||
pendingStageFiles: new Set(),
|
||||
onOpenDiff: vi.fn(),
|
||||
onEditFile: vi.fn(),
|
||||
onStage: vi.fn(),
|
||||
onUnstage: vi.fn(),
|
||||
onDiscard: vi.fn(),
|
||||
};
|
||||
|
||||
function file(path: string, repo?: string): Props["files"][number] {
|
||||
return {
|
||||
path,
|
||||
status: "modified",
|
||||
staged: false,
|
||||
plus: 1,
|
||||
minus: 0,
|
||||
oldPath: undefined,
|
||||
repositoryName: repo,
|
||||
};
|
||||
}
|
||||
|
||||
describe("FileListSection — multi-repo grouping", () => {
|
||||
it("renders no repo header when files lack a repository_name (single-repo)", () => {
|
||||
render(
|
||||
<FileListSection
|
||||
{...baseProps}
|
||||
variant="unstaged"
|
||||
isLast={false}
|
||||
actionLabel="Stage all"
|
||||
onAction={() => undefined}
|
||||
files={[file("a.ts"), file("b.ts")]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryAllByTestId("changes-repo-header")).toHaveLength(0);
|
||||
expect(screen.getAllByTestId("file-row")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders one header per repo when 2+ repos are present", () => {
|
||||
render(
|
||||
<FileListSection
|
||||
{...baseProps}
|
||||
variant="unstaged"
|
||||
isLast={false}
|
||||
actionLabel="Stage all"
|
||||
onAction={() => undefined}
|
||||
files={[
|
||||
file("src/app.tsx", "frontend"),
|
||||
file("src/api.ts", "frontend"),
|
||||
file("handlers/task.go", "backend"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const headers = screen.getAllByTestId("changes-repo-header");
|
||||
expect(headers).toHaveLength(2);
|
||||
expect(headers[0].textContent).toContain("frontend");
|
||||
expect(headers[0].textContent).toContain("2");
|
||||
expect(headers[1].textContent).toContain("backend");
|
||||
expect(headers[1].textContent).toContain("1");
|
||||
});
|
||||
|
||||
it("shows a header for a single named repo too", () => {
|
||||
render(
|
||||
<FileListSection
|
||||
{...baseProps}
|
||||
variant="unstaged"
|
||||
isLast={false}
|
||||
actionLabel="Stage all"
|
||||
onAction={() => undefined}
|
||||
files={[file("a.ts", "only-repo")]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("changes-repo-header").textContent).toContain("only-repo");
|
||||
});
|
||||
});
|
||||
@@ -34,8 +34,32 @@ type ChangedFile = {
|
||||
plus: number | undefined;
|
||||
minus: number | undefined;
|
||||
oldPath: string | undefined;
|
||||
/** Repository this file belongs to in multi-repo workspaces; empty for single-repo. */
|
||||
repositoryName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups changed files by repository name, preserving original order within
|
||||
* each group. Returns an empty-name leading group when files lack a
|
||||
* repository name (single-repo workspaces) so the renderer can skip the
|
||||
* per-repo header in that case.
|
||||
*/
|
||||
function groupChangedFilesByRepository(
|
||||
files: ChangedFile[],
|
||||
): Array<{ repositoryName: string; files: ChangedFile[] }> {
|
||||
const order: string[] = [];
|
||||
const buckets = new Map<string, ChangedFile[]>();
|
||||
for (const file of files) {
|
||||
const name = file.repositoryName ?? "";
|
||||
if (!buckets.has(name)) {
|
||||
buckets.set(name, []);
|
||||
order.push(name);
|
||||
}
|
||||
buckets.get(name)!.push(file);
|
||||
}
|
||||
return order.map((name) => ({ repositoryName: name, files: buckets.get(name)! }));
|
||||
}
|
||||
|
||||
// --- Timeline section dot colors ---
|
||||
const DOT_COLORS = {
|
||||
unstaged: "bg-yellow-500",
|
||||
@@ -297,6 +321,80 @@ type FileListSectionProps = {
|
||||
onBulkDiscard?: (paths: string[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the body of a file-list section. For multi-repo workspaces it
|
||||
* groups files by repository under per-repo subheaders; single-repo
|
||||
* workspaces fall back to a flat list (no header) so the existing UI is
|
||||
* unchanged.
|
||||
*/
|
||||
function FileListBody(props: {
|
||||
variant: "unstaged" | "staged";
|
||||
files: ChangedFile[];
|
||||
pendingStageFiles: Set<string>;
|
||||
multiSelect: ReturnType<typeof useMultiSelect>;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onOpenDiff: (path: string) => void;
|
||||
onEditFile: (path: string) => void;
|
||||
onStage: (path: string) => void;
|
||||
onUnstage: (path: string) => void;
|
||||
onDiscard: (path: string) => void;
|
||||
}) {
|
||||
const { variant, files, pendingStageFiles, multiSelect } = props;
|
||||
const groups = useMemo(() => groupChangedFilesByRepository(files), [files]);
|
||||
const showRepoHeaders = groups.length > 1 || (groups[0]?.repositoryName ?? "") !== "";
|
||||
|
||||
const renderRow = (file: ChangedFile) => (
|
||||
<FileRow
|
||||
key={`${file.repositoryName ?? ""}:${file.path}`}
|
||||
file={file}
|
||||
isPending={pendingStageFiles.has(file.path)}
|
||||
isSelected={multiSelect.isSelected(file.path)}
|
||||
onSelect={multiSelect.handleClick}
|
||||
onOpenDiff={props.onOpenDiff}
|
||||
onStage={props.onStage}
|
||||
onUnstage={props.onUnstage}
|
||||
onDiscard={props.onDiscard}
|
||||
onEditFile={props.onEditFile}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul
|
||||
data-testid={`${variant}-file-list`}
|
||||
className="space-y-0.5"
|
||||
tabIndex={-1}
|
||||
onKeyDown={props.onKeyDown}
|
||||
>
|
||||
{groups.map((group) =>
|
||||
showRepoHeaders ? (
|
||||
<li
|
||||
key={group.repositoryName || "__no_repo__"}
|
||||
data-testid="changes-repo-group"
|
||||
data-repository-name={group.repositoryName || ""}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-1 py-0.5 text-[11px] font-medium text-muted-foreground/80 uppercase tracking-wide"
|
||||
data-testid="changes-repo-header"
|
||||
>
|
||||
<span className="truncate">{group.repositoryName || "Other"}</span>
|
||||
<span className="text-muted-foreground/50 normal-case tracking-normal">
|
||||
{group.files.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5">{group.files.map(renderRow)}</ul>
|
||||
</li>
|
||||
) : (
|
||||
// Single-repo flat list: render rows inline so the DOM shape stays
|
||||
// identical to the pre-multi-repo behavior.
|
||||
group.files.map(renderRow)
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileListSection(props: FileListSectionProps) {
|
||||
const {
|
||||
variant,
|
||||
@@ -332,29 +430,18 @@ export function FileListSection(props: FileListSectionProps) {
|
||||
data-testid={`${variant}-files-section`}
|
||||
>
|
||||
{files.length > 0 && (
|
||||
<div>
|
||||
<ul
|
||||
data-testid={`${variant}-file-list`}
|
||||
className="space-y-0.5"
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{files.map((file) => (
|
||||
<FileRow
|
||||
key={file.path}
|
||||
file={file}
|
||||
isPending={pendingStageFiles.has(file.path)}
|
||||
isSelected={multiSelect.isSelected(file.path)}
|
||||
onSelect={multiSelect.handleClick}
|
||||
onOpenDiff={onOpenDiff}
|
||||
onStage={onStage}
|
||||
onUnstage={onUnstage}
|
||||
onDiscard={onDiscard}
|
||||
onEditFile={onEditFile}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<FileListBody
|
||||
variant={variant}
|
||||
files={files}
|
||||
pendingStageFiles={pendingStageFiles}
|
||||
multiSelect={multiSelect}
|
||||
onKeyDown={handleKeyDown}
|
||||
onOpenDiff={onOpenDiff}
|
||||
onStage={onStage}
|
||||
onUnstage={onUnstage}
|
||||
onDiscard={onDiscard}
|
||||
onEditFile={onEditFile}
|
||||
/>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
|
||||
@@ -43,6 +43,8 @@ type ChangedFile = {
|
||||
plus: number | undefined;
|
||||
minus: number | undefined;
|
||||
oldPath: string | undefined;
|
||||
/** Repository this file belongs to in multi-repo workspaces; empty for single-repo. */
|
||||
repositoryName?: string;
|
||||
};
|
||||
|
||||
function mapToChangedFiles(files: FileInfo[]): ChangedFile[] {
|
||||
@@ -53,6 +55,7 @@ function mapToChangedFiles(files: FileInfo[]): ChangedFile[] {
|
||||
plus: file.additions,
|
||||
minus: file.deletions,
|
||||
oldPath: file.old_path,
|
||||
repositoryName: file.repository_name,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useSessionGitStatus } from "./use-session-git-status";
|
||||
import { useSessionGitStatus, useSessionGitStatusByRepo } from "./use-session-git-status";
|
||||
import { useSessionCommits } from "./use-session-commits";
|
||||
import { useCumulativeDiff } from "./use-cumulative-diff";
|
||||
import { useGitOperations } from "@/hooks/use-git-operations";
|
||||
@@ -72,18 +72,44 @@ export type SessionGit = {
|
||||
) => Promise<PRCreateResult>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the SessionGit's flat file list. For multi-repo workspaces it
|
||||
* stamps each FileInfo with its repository_name so consumers can group;
|
||||
* for single-repo it returns the legacy single-status files unchanged.
|
||||
*/
|
||||
function aggregateFilesAcrossRepos(
|
||||
statusByRepo: ReturnType<typeof useSessionGitStatusByRepo>,
|
||||
gitStatus: ReturnType<typeof useSessionGitStatus>,
|
||||
): FileInfo[] {
|
||||
if (statusByRepo.length > 0) {
|
||||
const out: FileInfo[] = [];
|
||||
for (const { repository_name, status } of statusByRepo) {
|
||||
if (!status?.files) continue;
|
||||
for (const f of Object.values(status.files)) {
|
||||
out.push(repository_name ? { ...f, repository_name } : f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return gitStatus?.files ? Object.values(gitStatus.files) : [];
|
||||
}
|
||||
|
||||
export function useSessionGit(sessionId: string | null | undefined): SessionGit {
|
||||
const sid = sessionId ?? null;
|
||||
const gitStatus = useSessionGitStatus(sid);
|
||||
const statusByRepo = useSessionGitStatusByRepo(sid);
|
||||
const { commits, loading: commitsLoading } = useSessionCommits(sid);
|
||||
const { diff: cumulativeDiff } = useCumulativeDiff(sid);
|
||||
const gitOps = useGitOperations(sid);
|
||||
|
||||
const [pendingStageFiles, setPendingStageFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
// Multi-repo: aggregate files from every repo's per-repo status, stamping
|
||||
// each FileInfo with its repository_name so downstream UI can group them.
|
||||
// Single-repo (or repo-less) sessions fall back to the legacy single status.
|
||||
const allFiles = useMemo<FileInfo[]>(
|
||||
() => (gitStatus?.files ? Object.values(gitStatus.files) : []),
|
||||
[gitStatus],
|
||||
() => aggregateFilesAcrossRepos(statusByRepo, gitStatus),
|
||||
[statusByRepo, gitStatus],
|
||||
);
|
||||
const unstagedFiles = useMemo(() => allFiles.filter((f) => !f.staged), [allFiles]);
|
||||
const stagedFiles = useMemo(() => allFiles.filter((f) => f.staged), [allFiles]);
|
||||
|
||||
@@ -49,6 +49,13 @@ export type FileInfo = {
|
||||
old_path?: string;
|
||||
diff?: string;
|
||||
diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded";
|
||||
/**
|
||||
* Repository this file belongs to in multi-repo task workspaces. Stamped
|
||||
* by useSessionGit when aggregating per-repo statuses; empty for single-
|
||||
* repo workspaces. The Changes panel uses it to group files under
|
||||
* per-repository headers.
|
||||
*/
|
||||
repository_name?: string;
|
||||
};
|
||||
|
||||
export type GitStatusEntry = {
|
||||
|
||||
Reference in New Issue
Block a user