mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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": "显示所有提交"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user