From d94499b7acaeaafec80eec0a16107ef0183f480f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:20:58 +0800 Subject: [PATCH] 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. --- src/features/vcs/PushChanges.tsx | 88 +++++++++++++++++++ .../vcs/__tests__/pushChanges.test.tsx | 44 ++++++++++ src/features/vcs/common.module.css | 18 ++++ src/features/vcs/remoteUi.tsx | 32 ++++++- src/platform/locales/text/zh.json | 3 +- 5 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 src/features/vcs/PushChanges.tsx create mode 100644 src/features/vcs/__tests__/pushChanges.test.tsx diff --git a/src/features/vcs/PushChanges.tsx b/src/features/vcs/PushChanges.tsx new file mode 100644 index 00000000..ad6753cc --- /dev/null +++ b/src/features/vcs/PushChanges.tsx @@ -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(null); + const [error, setError] = useState(null); + const [current, setCurrent] = useState(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 ( +
+
+ + {changes ? fileCount(changes.length) : ""} + {count > 1 ? " " + tx`in ${count} commits` : ""} + + {onShowAll && ( + + )} +
+ {error ? ( +
{error}
+ ) : !changes ? ( +
+ +
+ ) : ( + c.diff} + getColor={(c) => KIND_COLOR[c.kind === "untracked" ? "added" : c.kind]} + ariaLabel="Changed files" + renderDetails={(n) => (n.file ? null : {n.count})} + /> + )} +
+ ); +} diff --git a/src/features/vcs/__tests__/pushChanges.test.tsx b/src/features/vcs/__tests__/pushChanges.test.tsx new file mode 100644 index 00000000..1cdbf94b --- /dev/null +++ b/src/features/vcs/__tests__/pushChanges.test.tsx @@ -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(); + 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(); + 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(); + expect(await screen.findByText("a.txt")).toBeTruthy(); + expect(files).toHaveBeenCalledWith("/r", "aaaaaaaaaa"); + fireEvent.click(screen.getByRole("button", { name: "Show All Commits" })); + expect(showAll).toHaveBeenCalled(); + }); +}); diff --git a/src/features/vcs/common.module.css b/src/features/vcs/common.module.css index 766a0785..34685628 100644 --- a/src/features/vcs/common.module.css +++ b/src/features/vcs/common.module.css @@ -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; diff --git a/src/features/vcs/remoteUi.tsx b/src/features/vcs/remoteUi.tsx index faa007e7..63554591 100644 --- a/src/features/vcs/remoteUi.tsx +++ b/src/features/vcs/remoteUi.tsx @@ -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(null); const [checked, setChecked] = useState>(() => new Set()); const [collapsed, setCollapsed] = useState>(() => new Set()); + // The selected row: a repository shows all its outgoing changes, a commit its own. + const [shown, setShown] = useState(null); const [error, setError] = useState(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 ( 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 }) { /> )} + {shownRepo && ( + + )} ); } @@ -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(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(null); const [force, setForce] = useState(false); const [remotes, setRemotes] = useState([]); @@ -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 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 /> )} + = 0 && (commits?.length ?? 0) > 1 ? () => setShownCommit(-1) : undefined} + /> ); } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 05fe8274..cee42fda 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "显示所有提交" }