feat(vcs): the Push dialog shows the outgoing commits' changed files (#1105)

Below the outgoing commits, the Push dialog lists the files they change: all outgoing commits' combined changes by default, or the selected commit's (Show All Commits goes back). Selecting a file shows its diff beside the tree inside the modal dialog. The multi-repository Push dialog does the same for the selected repository or commit.
This commit is contained in:
l0ng-ai
2026-11-04 05:20:58 +08:00
committed by GitHub
parent f148c1ac6b
commit d94499b7ac
5 changed files with 181 additions and 4 deletions
+88
View File
@@ -0,0 +1,88 @@
/**
* Push dialog: the files changed by the outgoing commits (the selected commit's, or all of
* them), with the selected file's diff beside the tree. The diff stays inside the dialog,
* which is modal.
*/
import { useEffect, useMemo, useState } from "react";
import type { ChangedFile, Commit, RevisionChange } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { Button, Spinner, T } from "@/ui";
import { ComparisonTree } from "./ComparisonTree";
import { buildFileTree } from "./fileTree";
import { toLogChanges, type LogChange } from "./logChanges";
import { KIND_COLOR, fileCount } from "./model";
import styles from "./common.module.css";
/** Identifies what `PushChanges` shows; render it with this `key` so a new selection starts afresh. */
export const pushChangesKey = (repo: string | null, commits: readonly Commit[]) =>
`${repo ?? ""}:${commits.map((c) => c.id).join(" ")}`;
/**
* `commits` newest first, as the dialog lists them (stable across renders). `onShowAll`: a single
* commit is shown; offers going back to the changes of all outgoing commits.
*/
export function PushChanges({
repo,
commits,
onShowAll,
}: {
repo: string | null;
commits: readonly Commit[];
onShowAll?: () => void;
}) {
const [changes, setChanges] = useState<LogChange[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [current, setCurrent] = useState<LogChange | null>(null);
useEffect(() => {
if (!commits.length) return;
let alive = true;
const single = commits.length === 1 ? commits[0]! : null;
const load: Promise<(ChangedFile | RevisionChange)[]> = single
? call(commands.vcsCommitFiles(repo, single.id))
: call(commands.vcsCommitsChanges(repo, commits.map((c) => c.id).reverse()));
load
.then((f) => alive && setChanges(toLogChanges(f, single, repo)))
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
return () => {
alive = false;
};
}, [repo, commits]);
const tree = useMemo(() => (changes ? buildFileTree(changes, (f) => f.path) : []), [changes]);
const count = commits.length;
if (!count) return null;
return (
<div className={styles.pushChanges}>
<div className={styles.pushChangesTitle}>
<span>
{changes ? fileCount(changes.length) : ""}
{count > 1 ? " " + tx`in ${count} commits` : ""}
</span>
{onShowAll && (
<Button variant="ghost" size="small" onClick={onShowAll}>
<T>Show All Commits</T>
</Button>
)}
</div>
{error ? (
<div className={styles.error}>{error}</div>
) : !changes ? (
<div className={styles.empty}>
<Spinner size={14} />
</div>
) : (
<ComparisonTree
tree={tree}
current={current}
onSelect={setCurrent}
diffFor={(c) => c.diff}
getColor={(c) => KIND_COLOR[c.kind === "untracked" ? "added" : c.kind]}
ariaLabel="Changed files"
renderDetails={(n) => (n.file ? null : <span className={styles.fileDir}>{n.count}</span>)}
/>
)}
</div>
);
}
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { Commit } from "@/bindings";
const { files, combined } = vi.hoisted(() => ({ files: vi.fn(), combined: vi.fn() }));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return { ...real, commands: { ...real.commands, vcsCommitFiles: files, vcsCommitsChanges: combined } };
});
import { PushChanges } from "../PushChanges";
const commit = (id: string, parent: string): Commit =>
({ id, shortId: id.slice(0, 7), parents: [parent], summary: id, author: "me", timeMs: 0 }) as unknown as Commit;
const newer = commit("bbbbbbbbbb", "aaaaaaaaaa");
const older = commit("aaaaaaaaaa", "9999999999");
describe("Push dialog changes", () => {
it("lists the combined changes of all outgoing commits, oldest first to the backend", async () => {
combined.mockResolvedValue({
status: "ok",
data: [
{ path: "src/app/Main.java", oldPath: null, kind: "modified", before: "9999999999", after: "bbbbbbbbbb" },
{ path: "README.md", oldPath: null, kind: "added", before: null, after: "bbbbbbbbbb" },
],
});
render(<PushChanges repo="/r" commits={[newer, older]} />);
expect(await screen.findByText("Main.java")).toBeTruthy();
expect(screen.getByText("README.md")).toBeTruthy();
expect(screen.getByText(/2 files in 2 commits/)).toBeTruthy();
expect(combined).toHaveBeenCalledWith("/r", ["aaaaaaaaaa", "bbbbbbbbbb"]);
});
it("shows one commit's files and offers going back to all commits", async () => {
files.mockResolvedValue({ status: "ok", data: [{ path: "a.txt", oldPath: null, kind: "deleted" }] });
const showAll = vi.fn();
render(<PushChanges repo="/r" commits={[older]} onShowAll={showAll} />);
expect(await screen.findByText("a.txt")).toBeTruthy();
expect(files).toHaveBeenCalledWith("/r", "aaaaaaaaaa");
fireEvent.click(screen.getByRole("button", { name: "Show All Commits" }));
expect(showAll).toHaveBeenCalled();
});
});
+18
View File
@@ -125,6 +125,24 @@
flex-direction: column;
padding: 0 14px 14px;
}
/* Push dialogs: the outgoing commits' changed files below the commits, the diff beside them. */
.pushChanges {
display: flex;
flex-direction: column;
min-height: 0;
}
.pushChanges .comparison {
height: min(360px, 45vh);
padding: 0 14px 14px;
}
.pushChangesTitle {
display: flex;
align-items: center;
gap: 8px;
min-height: 24px;
padding: 0 14px 6px;
color: var(--fg-subtle);
}
.todoList {
margin: 8px 0 0;
padding-left: 18px;
+29 -3
View File
@@ -1,5 +1,5 @@
/** Update Project and Push dialogs. */
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { BranchInfo, Commit, PushTags, RemoteOp } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
@@ -10,6 +10,7 @@ import { ask } from "@/workbench/messages";
import { openDialog } from "./dialogs";
import { formatTime } from "./model";
import { allRepos, lastRemoteFailure, runRemote, saveAll } from "./ops";
import { PushChanges, pushChangesKey } from "./PushChanges";
import { currentRepo, refreshNow, repoName, useVcs } from "./store";
import styles from "./common.module.css";
@@ -153,6 +154,8 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) {
const [repos, setRepos] = useState<RepoPush[] | null>(null);
const [checked, setChecked] = useState<Set<string>>(() => new Set());
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
// The selected row: a repository shows all its outgoing changes, a commit its own.
const [shown, setShown] = useState<PushNode | null>(null);
const [error, setError] = useState<string | null>(null);
const [force, setForce] = useState(false);
const [pushTags, setPushTags] = useState(false);
@@ -216,12 +219,15 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) {
children: repo.commits.map((commit) => ({ key: `${repo.root}:${commit.id}`, repo, commit })),
}));
const shownRepo = shown?.repo ?? (repos?.length === 1 ? repos[0]! : null);
const shownCommits = useMemo(() => (shown?.commit ? [shown.commit] : (shownRepo?.commits ?? [])), [shown, shownRepo]);
return (
<Dialog
open
title="Push Commits"
onClose={onClose}
width={620}
width={repos?.some((r) => r.commits.length) ? 860 : 620}
resizable="both"
footerStart={
<>
@@ -273,6 +279,9 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) {
return s;
})
}
selected={new Set(shown ? [shown.key] : [])}
multiSelect={false}
onSelectionChange={(_sel, lead) => setShown(lead)}
rowHeight={22}
aria-label="Outgoing commits"
className={styles.boxList}
@@ -312,6 +321,9 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) {
/>
)}
</div>
{shownRepo && (
<PushChanges key={pushChangesKey(shownRepo.root, shownCommits)} repo={shownRepo.root} commits={shownCommits} />
)}
</Dialog>
);
}
@@ -321,6 +333,12 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
const [branch] = useState(() => useVcs.getState().branch);
const [branches] = useState(() => useVcs.getState().branches);
const [commits, setCommits] = useState<Commit[] | null>(null);
// The commit whose changes are shown; -1: all outgoing commits.
const [shownCommit, setShownCommit] = useState(-1);
const shownCommits = useMemo(
() => (commits && shownCommit >= 0 && commits[shownCommit] ? [commits[shownCommit]] : (commits ?? [])),
[commits, shownCommit],
);
const [error, setError] = useState<string | null>(null);
const [force, setForce] = useState(false);
const [remotes, setRemotes] = useState<string[]>([]);
@@ -422,7 +440,7 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
open
title={head ? tx`Push Commits to ${upstream ?? "remote"}` : "Push"}
onClose={onClose}
width={620}
width={commits?.length ? 860 : 620}
resizable="both"
footerStart={
<>
@@ -506,6 +524,8 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
<List
items={commits}
getKey={(c) => c.id}
activeIndex={shownCommit}
onActiveChange={setShownCommit}
rowHeight={22}
aria-label="Outgoing commits"
className={styles.boxList}
@@ -521,6 +541,12 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
/>
)}
</div>
<PushChanges
key={pushChangesKey(repo, shownCommits)}
repo={repo}
commits={shownCommits}
onShowAll={shownCommit >= 0 && (commits?.length ?? 0) > 1 ? () => setShownCommit(-1) : undefined}
/>
</Dialog>
);
}
+2 -1
View File
@@ -5786,5 +5786,6 @@
"Syntax errors": "语法错误",
"Reports code the built-in parser cannot read, in files no language server checks (none is configured for the language, or it is not installed, stopped or crashed). A running language server reports syntax errors itself.": "报告内置解析器无法解析的代码,仅针对没有语言服务器检查的文件(该语言未配置语言服务器,或未安装、已停止、已崩溃)。语言服务器运行时由它自己报告语法错误。",
"Some items could not be restored": "部分项目无法恢复",
"Not in Local History: {names}. They are in the Trash.": "本地历史中没有:{names}。它们在回收站中。"
"Not in Local History: {names}. They are in the Trash.": "本地历史中没有:{names}。它们在回收站中。",
"Show All Commits": "显示所有提交"
}