feat(vcs): compare folder selections with revisions across repositories (#602)

Compare with Branch or Revision only handled one file, so reviewing a folder meant opening each file. Folders, the project root and multiple selections now open a changes tree against the chosen revision, with per-file diffs on demand. A single file keeps its direct diff.

Each path resolves against its own repository through the root registry. In a multi-repo workspace the picker offers shared revisions such as "main in 3 repositories" as well as per-repository ones, results are grouped by repository, and content outside any repository or a repository missing the revision is stated in the result. Listing is cancellable and off the UI thread, includes worktree and untracked changes, and a rename leaving the selection shows as a delete. Show Diff on folders reuses the same tree.

Verified on macOS, and against a remote host over the server protocol. A remote window, Windows and Linux were not run.

Fixes #567
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 01:43:15 +08:00
1 джерело 0a71f6be88
коміт 9e35cfc064
20 files changed
+1404 -121

No files matched your search

+32
Переглянути файл
@@ -509,6 +509,8 @@ export const commands = {
vcsRemoveWorktree: (repo: string | null, path: string, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_remove_worktree", { repo, path, force })),
/** Compare with Local: tracked files that differ between `rev` and the working tree. */
vcsDiffWithWorktree: (repo: string | null, rev: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })),
vcsCompareScope: (paths: string[]) => typedError<CompareScope, IdeError>(__TAURI_INVOKE("vcs_compare_scope", { paths })),
vcsComparePaths: (paths: string[], revision: string, repositories: string[] | null, request: RequestId) => typedError<CompareResult, IdeError>(__TAURI_INVOKE("vcs_compare_paths", { paths, revision, repositories, request })),
/** Files that differ between two revisions (the "N files updated" of Update Project). */
vcsDiffRevisions: (repo: string | null, from: string, to: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_revisions", { repo, from, to })),
/** The best common ancestor of `a` and `b` (`git merge-base`), if any. */
@@ -1888,6 +1890,18 @@ export type CommitSettings = {
wrapAtMargin?: boolean,
};
export type CompareResult = {
repositories: RepositoryComparison[],
skippedPaths: IpcPath[],
};
export type CompareScope = {
repositories: IpcPath[],
/** Selected content outside registered repositories; workspace parents expand to their skipped children. */
skippedPaths: IpcPath[],
singleFile: boolean,
};
export type CompletionItem = {
label: string,
/** e.g. `(req: CreateOrderRequest)` shown right after the label. */
@@ -5125,6 +5139,16 @@ export type ReplaceTarget = {
only?: Pos[] | null,
};
export type RepositoryComparison = {
root: IpcPath,
/** Resolved commit, so opening a diff later uses the same left side as the listing. */
revision: string | null,
files: WorkingTreeChange[],
error: string | null,
/** The requested revision does not resolve to a commit in this repository. */
missingRevision?: boolean,
};
/** A cancellable, streaming backend request issued by the UI. */
export type RequestId = number;
@@ -6294,6 +6318,14 @@ export type WindowTab = {
current: boolean,
};
export type WorkingTreeChange = {
path: string,
kind: ChangeKind,
oldPath: string | null,
added: number,
removed: number,
};
export type Workspace = Workspace_Serialize | Workspace_Deserialize;
/** Edits to many files, applied atomically from the user's point of view. */
+12
Переглянути файл
@@ -44,6 +44,7 @@ const OTHER_ACTIONS = [
"vcs.addToVcs",
"vcs.annotate",
"vcs.showDiff",
"vcs.compareWithBranch",
"vcs.rollback",
"vcs.push",
];
@@ -129,6 +130,17 @@ describe("project view context menu", () => {
}
});
it("offers branch comparison for files, folders, their union and the project root", () => {
for (const nodes of [
[node("/p/a.py")],
[node("/p/src", true)],
[node("/p/src", true), node("/p/lib", true)],
[node("/p", true, true)],
]) {
expect(sub(contextMenuItems(nodes), "Git")).toContain("Compare with Branch…");
}
});
it("offers Show History for files, directories, multi-selection and the project root", () => {
const dispose = registerActions([{ id: "vcs.showHistory", title: "Show History", run: () => undefined }]);
try {
+3 -1
Переглянути файл
@@ -335,7 +335,9 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
{ kind: "separator" },
singleFile && act("vcs.annotate", singleFile.path, "Annotate with Git Blame"),
act("vcs.showDiff", arg, "Show Diff"),
singleFile && act("vcs.compareWithBranch", { path: singleFile.path }, "Compare with Branch…"),
nodes.length > 0 &&
nodes.every((n) => !n.placeholder) &&
act("vcs.compareWithBranch", arg, "Compare with Branch…"),
nodes.length > 0 && nodes.every((n) => !n.placeholder) && act("vcs.showHistory", arg, "Show History"),
act("vcs.rollback", arg, "Rollback…"),
{ kind: "separator" },
+117 -44
Переглянути файл
@@ -1,26 +1,100 @@
/** Chooser for "Compare with Branch or Revision…". */
/** Chooser for revision actions, including a selection spanning multiple repositories. */
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { Dialog, HighlightedText, Icon, List, SearchField, fuzzyMatch, listNavigate } from "@/ui";
import { sortLocal, sortRemote } from "./branches";
import { openDialog } from "./dialogs";
import { call, commands } from "@/platform/ipc";
import type { Branch } from "@/bindings";
import { currentRepo } from "./store";
import { currentRepo, repoName } from "./store";
import { tx } from "@/platform/i18n";
import styles from "./common.module.css";
interface Item {
export interface RevisionChoice {
rev: string;
/** null applies an entered revision to every repository in the selection. */
repositories: string[] | null;
}
interface Item extends RevisionChoice {
detail: string;
matches: number[] | null;
section?: boolean;
}
export interface RepositoryRevisions {
root: string | null;
branches: Branch[];
tags: string[];
}
export interface ChooseRevisionOptions {
/** Offer the repository's tags too. */
tags?: boolean;
placeholder?: string;
repositories?: string[];
}
export function chooseRevision(title: string, options: ChooseRevisionOptions = {}): Promise<string | null> {
/** Shared branches first, followed by each repository's revisions. */
export function revisionItems(repos: RepositoryRevisions[], query: string): Item[] {
const all: Item[] = [{ rev: "HEAD", detail: tx("Last commit"), repositories: null, matches: null }];
const multi = repos.length > 1;
if (multi) {
const shared = new Map<string, string[]>();
for (const repo of repos) {
for (const name of new Set(repo.branches.map((b) => b.name))) {
const roots = shared.get(name) ?? [];
if (repo.root) roots.push(repo.root);
shared.set(name, roots);
}
}
for (const [rev, repositories] of shared) {
if (repositories.length > 1)
all.push({ rev, detail: tx`in ${repositories.length} repositories`, repositories, matches: null });
}
}
for (const repo of repos) {
if (multi)
all.push({
rev: repoName(repo.root ?? ""),
detail: repo.root ?? "",
repositories: [],
matches: null,
section: true,
});
const repositories = repo.root ? [repo.root] : null;
all.push(
...sortLocal(repo.branches).map((b) => ({
rev: b.name,
detail: tx(b.current ? "current branch" : "local"),
repositories,
matches: null,
})),
...sortRemote(repo.branches).map((b) => ({ rev: b.name, detail: tx("remote"), repositories, matches: null })),
...repo.tags.map((rev) => ({ rev, detail: tx("tag"), repositories, matches: null })),
);
}
const q = query.trim();
if (!q) return all;
const filtered = all
.flatMap((i) => {
if (i.section) return [i];
const matches = fuzzyMatch(i.rev, q);
return matches ? [{ ...i, matches }] : [];
})
.filter((i, idx, list) => !i.section || (!!list[idx + 1] && !list[idx + 1]!.section));
// Always allow applying a typed revision across the full selection, even when one repo offers it.
if (!filtered.some((i) => !i.section && i.rev === q && i.repositories === null))
filtered.push({ rev: q, detail: tx("revision"), repositories: null, matches: null });
return filtered;
}
export async function chooseRevision(title: string, options: ChooseRevisionOptions = {}): Promise<string | null> {
return (await chooseRepositoryRevision(title, options))?.rev ?? null;
}
export function chooseRepositoryRevision(
title: string,
options: ChooseRevisionOptions,
): Promise<RevisionChoice | null> {
return new Promise((resolve) => {
const close = openDialog(() => (
<Chooser
@@ -42,57 +116,46 @@ function Chooser({
}: {
title: string;
options: ChooseRevisionOptions;
onDone: (rev: string | null) => void;
onDone: (rev: RevisionChoice | null) => void;
}) {
const [repo] = useState(currentRepo);
const [branches, setBranches] = useState<Branch[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [roots] = useState(() => options.repositories ?? [currentRepo()]);
const [repos, setRepos] = useState<RepositoryRevisions[]>([]);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
useEffect(() => {
let alive = true;
void call(commands.vcsBranches(repo))
.then((b) => alive && setBranches(b))
.catch(() => undefined);
if (options.tags) {
void call(commands.vcsTags(repo))
.then((t) => alive && setTags(t))
.catch(() => undefined);
}
void Promise.all(
roots.map(async (root) => {
const [branches, tags] = await Promise.allSettled([
call(commands.vcsBranches(root)),
options.tags ? call(commands.vcsTags(root)) : Promise.resolve<string[]>([]),
]);
for (const outcome of [branches, tags])
if (outcome.status === "rejected" && alive) setError(`${root ?? ""}: ${String(outcome.reason)}`);
return {
root,
branches: branches.status === "fulfilled" ? branches.value : [],
tags: tags.status === "fulfilled" ? tags.value : [],
};
}),
)
.then((r) => alive && setRepos(r))
.catch((e) => alive && setError(String(e)));
return () => {
alive = false;
};
}, [options.tags]);
const items = useMemo(() => {
const all: Item[] = [
{ rev: "HEAD", detail: "Last commit", matches: null },
...sortLocal(branches).map((b) => ({
rev: b.name,
detail: b.current ? "current branch" : "local",
matches: null,
})),
...sortRemote(branches).map((b) => ({ rev: b.name, detail: "remote", matches: null })),
...tags.map((t) => ({ rev: t, detail: "tag", matches: null })),
];
const q = query.trim();
const filtered = q
? all.flatMap((i) => {
const m = fuzzyMatch(i.rev, q);
return m ? [{ ...i, matches: m }] : [];
})
: all;
if (q && !filtered.some((i) => i.rev === q)) filtered.push({ rev: q, detail: "revision", matches: null });
return filtered;
}, [branches, tags, query]);
}, [options.tags, roots]);
const items = useMemo(() => revisionItems(repos, query), [repos, query]);
const idx = Math.min(active, items.length - 1);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
const it = items[idx];
if (it) onDone(it.rev);
if (it && !it.section) onDone(it);
return;
}
const n = listNavigate(e.key, { items, active: idx, wrap: true });
const n = listNavigate(e.key, { items, active: idx, isSection: (i) => !!i.section, wrap: true });
if (n !== null) {
e.preventDefault();
setActive(n);
@@ -114,13 +177,20 @@ function Chooser({
variant="bare"
/>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div style={{ height: 280, display: "flex", flexDirection: "column", padding: "4px 0" }}>
<List
items={items}
getKey={(i) => `${i.detail}:${i.rev}`}
getKey={(i) => `${i.repositories?.join(":")}:${i.detail}:${i.rev}`}
activeIndex={idx}
onActiveChange={setActive}
onActivate={(i) => onDone(i.rev)}
onActivate={(i) => onDone(i)}
isSection={(i) => !!i.section}
renderSection={(i) => <span title={i.detail}>{i.rev}</span>}
focusable={false}
hoverSelects
rowHeight={24}
@@ -128,7 +198,10 @@ function Chooser({
className={styles.grow}
renderItem={(i) => (
<span className={styles.fileRow} style={{ flex: 1, padding: 0 }}>
<Icon name={i.detail === "revision" ? "commit" : i.detail === "tag" ? "tag" : "branch"} size={14} />
<Icon
name={i.detail === tx("revision") ? "commit" : i.detail === tx("tag") ? "tag" : "branch"}
size={14}
/>
<HighlightedText
className={`${styles.fileName} ${styles.mono}`}
text={i.rev}
+26
Переглянути файл
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getAction, isActionEnabled } from "@/platform/actions";
import { provideProjectViewSelection } from "@/workbench/contributions";
import { registerVcsActions } from "../actions";
const mocks = vi.hoisted(() => ({ compare: vi.fn() }));
vi.mock("../comparePaths", () => ({ comparePathsWithRevision: mocks.compare, showDirectoryWorkingDiff: vi.fn() }));
afterEach(() => vi.clearAllMocks());
describe("compare action context", () => {
it("is enabled for Git folder selections and uses project selection in menu/Search Everywhere", async () => {
const unregister = registerVcsActions();
const dispose = provideProjectViewSelection(() => ["/workspace/client", "/workspace/server"]);
try {
const action = getAction("vcs.compareWithBranch")!;
expect(isActionEnabled(action, { vcsAvailable: true, projectViewFocus: true })).toBe(true);
await action.run({ arg: undefined, ctx: { vcsAvailable: true } });
expect(mocks.compare).toHaveBeenCalledWith(["/workspace/client", "/workspace/server"]);
await action.run({ arg: { path: "/workspace/client/a.ts" }, ctx: { vcsAvailable: true } });
expect(mocks.compare).toHaveBeenLastCalledWith(["/workspace/client/a.ts"]);
} finally {
dispose();
unregister();
}
});
});
+74
Переглянути файл
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ scope: vi.fn(), choose: vi.fn(), open: vi.fn(), show: vi.fn(), notify: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return { ...real, commands: { ...real.commands, vcsCompareScope: mocks.scope } };
});
vi.mock("../RevisionChooser", () => ({ chooseRepositoryRevision: mocks.choose }));
vi.mock("../diffInput", () => ({ openDiff: mocks.open }));
vi.mock("../operations", () => ({ showPathComparison: mocks.show }));
vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify: mocks.notify }) }));
import { useProject } from "@/platform/project";
import { comparePathsWithRevision, showDirectoryWorkingDiff } from "../comparePaths";
afterEach(() => {
vi.clearAllMocks();
useProject.setState({ project: null });
});
describe("comparison flow", () => {
it("keeps a single file's direct revision → worktree diff and uses its own repository's picker", async () => {
mocks.scope.mockResolvedValue({
status: "ok",
data: { repositories: ["/server"], singleFile: true, skippedPaths: [] },
});
mocks.choose.mockResolvedValue({ rev: "main", repositories: ["/server"] });
await comparePathsWithRevision(["/server/a.ts"]);
expect(mocks.choose).toHaveBeenCalledWith("Compare with Branch or Revision", {
repositories: ["/server"],
tags: true,
});
expect(mocks.open).toHaveBeenCalledWith({
path: "/server/a.ts",
left: { type: "rev", rev: "main", label: "main" },
right: { type: "worktree" },
});
expect(mocks.show).not.toHaveBeenCalled();
});
it("applies shared revisions across the selection so missing revisions are reported, and preserves repo-specific choices", async () => {
mocks.scope.mockResolvedValue({
status: "ok",
data: { repositories: ["/client", "/server", "/admin"], singleFile: false, skippedPaths: [] },
});
mocks.choose.mockResolvedValue({ rev: "main", repositories: ["/client", "/server"] });
await comparePathsWithRevision(["/workspace"]);
expect(mocks.show).toHaveBeenLastCalledWith(["/workspace"], "main", null);
mocks.choose.mockResolvedValue({ rev: "feature", repositories: ["/server"] });
await comparePathsWithRevision(["/workspace"]);
expect(mocks.show).toHaveBeenLastCalledWith(["/workspace"], "feature", ["/server"]);
});
it("reports non-repository selections before opening a picker", async () => {
mocks.scope.mockResolvedValue({
status: "ok",
data: { repositories: [], singleFile: false, skippedPaths: ["/workspace/loose/"] },
});
useProject.setState({ project: { root: "/workspace", name: "workspace" } as never });
await comparePathsWithRevision(["/workspace/loose"]);
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "Selection is outside Git repositories", body: "loose/" }),
);
expect(mocks.choose).not.toHaveBeenCalled();
});
it("reuses the tree against HEAD for Show Diff on folders", async () => {
mocks.scope.mockResolvedValue({
status: "ok",
data: { repositories: ["/server"], singleFile: false, skippedPaths: [] },
});
expect(await showDirectoryWorkingDiff(["/server/src"])).toBe(true);
expect(mocks.show).toHaveBeenCalledWith(["/server/src"], "HEAD", null);
});
});
+164
Переглянути файл
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { Branch, CompareResult } from "@/bindings";
const mocks = vi.hoisted(() => ({
compare: vi.fn(),
cancel: vi.fn(),
scope: vi.fn(),
choose: vi.fn(),
open: vi.fn(),
show: vi.fn(),
}));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
vcsComparePaths: mocks.compare,
cancelRequest: mocks.cancel,
vcsCompareScope: mocks.scope,
},
};
});
vi.mock("../DiffEditor", () => ({
DiffEditor: ({ input }: { input: unknown }) => <div data-testid="diff">{JSON.stringify(input)}</div>,
}));
import { useProject } from "@/platform/project";
import { ChangesBetweenDialog, comparisonEmpty } from "../operations";
import { revisionItems } from "../RevisionChooser";
const branch = (name: string, remote = false) => ({ name, remote, current: false, lastCommitMs: 0 }) as Branch;
const scope = { paths: ["/workspace"], repositories: null };
const result: CompareResult = {
skippedPaths: ["/workspace/loose/"],
repositories: [
{
root: "/workspace/client",
revision: "aaa",
error: null,
missingRevision: false,
files: [{ path: "src/app.ts", kind: "modified", oldPath: null, added: 3, removed: 1 }],
},
{ root: "/workspace/server", revision: null, error: null, missingRevision: true, files: [] },
],
};
beforeEach(() => {
vi.clearAllMocks();
useProject.setState({ project: { root: "/workspace", name: "workspace" } as never });
mocks.cancel.mockResolvedValue(undefined);
});
afterEach(() => {
cleanup();
useProject.setState({ project: null });
});
describe("directory comparison", () => {
it("offers shared branches plus each repository's local, remote and tag revisions", () => {
const repos = [
{
root: "/workspace/client",
branches: [branch("main"), branch("client-only"), branch("origin/main", true)],
tags: ["v1"],
},
{ root: "/workspace/server", branches: [branch("main"), branch("server-only")], tags: [] },
{ root: "/workspace/admin", branches: [branch("main")], tags: [] },
];
const items = revisionItems(repos, "");
expect(items.find((i) => i.rev === "main" && i.repositories?.length === 3)?.detail).toBe("in 3 repositories");
expect(items.filter((i) => i.section).map((i) => i.rev)).toEqual(["client", "server", "admin"]);
expect(items.find((i) => i.rev === "origin/main")?.repositories).toEqual(["/workspace/client"]);
expect(items.find((i) => i.rev === "v1")?.repositories).toEqual(["/workspace/client"]);
expect(revisionItems(repos, "client-only").some((i) => i.rev === "client-only" && i.repositories === null)).toBe(
true,
);
});
it("shows explicit empty results, skipped paths and repository-specific errors", async () => {
mocks.compare.mockResolvedValue({
status: "ok",
data: { ...result, repositories: result.repositories.map((r) => ({ ...r, files: [] })) },
});
render(<ChangesBetweenDialog from={{ rev: "main", label: "main" }} to={null} scope={scope} onClose={() => {}} />);
expect(await screen.findByText("No changes against main in 1 repository")).toBeInTheDocument();
expect(screen.getByText("server has no revision main")).toBeInTheDocument();
expect(screen.getByText(/Skipped content/)).toHaveTextContent("Skipped content outside Git repositories: loose/");
expect(comparisonEmpty("main", 2)).toBe("No changes against main in 2 repositories");
});
it("shows one calm state when every selected repository lacks the revision", async () => {
mocks.compare.mockResolvedValue({
status: "ok",
data: {
...result,
repositories: result.repositories.map((r) => ({ ...r, files: [], revision: null, missingRevision: true })),
},
});
render(
<ChangesBetweenDialog
from={{ rev: "missing-revision", label: "missing-revision" }}
to={null}
scope={scope}
onClose={() => {}}
/>,
);
expect(await screen.findByText("No repository in the selection has revision missing-revision")).toBeInTheDocument();
expect(screen.queryByText(/has no revision/)).toBeNull();
expect(screen.queryByText(/No changes against/)).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
});
it("keeps other repository errors visible", async () => {
mocks.compare.mockResolvedValue({
status: "ok",
data: {
...result,
repositories: [{ ...result.repositories[1], missingRevision: false, error: "fatal: bad config" }],
},
});
render(<ChangesBetweenDialog from={{ rev: "main", label: "main" }} to={null} scope={scope} onClose={() => {}} />);
expect(await screen.findByRole("alert")).toHaveTextContent("Cannot compare server against main: fatal: bad config");
expect(screen.queryByText(/No changes against/)).toBeNull();
});
it("caps the list of skipped project-relative children", async () => {
mocks.compare.mockResolvedValue({
status: "ok",
data: {
...result,
skippedPaths: Array.from({ length: 7 }, (_, i) => `/workspace/loose${i}/`),
},
});
render(<ChangesBetweenDialog from={{ rev: "main", label: "main" }} to={null} scope={scope} onClose={() => {}} />);
expect(await screen.findByText(/Skipped content/)).toHaveTextContent(
"Skipped content outside Git repositories: loose0/, loose1/, loose2/, loose3/, loose4/ and 2 more",
);
expect(screen.getByText(/Skipped content/)).not.toHaveTextContent("/workspace");
});
it("groups the existing changes tree by repo, includes counts and loads a diff only on click", async () => {
mocks.compare.mockResolvedValue({ status: "ok", data: result });
render(<ChangesBetweenDialog from={{ rev: "main", label: "main" }} to={null} scope={scope} onClose={() => {}} />);
expect(await screen.findByText("app.ts")).toBeInTheDocument();
expect(screen.getByText("client")).toBeInTheDocument();
expect(screen.getByText("+3")).toBeInTheDocument();
expect(screen.getByText("−1")).toBeInTheDocument();
expect(screen.queryByTestId("diff")).toBeNull();
fireEvent.mouseDown(screen.getByText("app.ts"), { button: 0 });
expect(screen.getByTestId("diff")).toHaveTextContent('"rev":"aaa"');
expect(screen.getByTestId("diff")).toHaveTextContent("/workspace/client/src/app.ts");
});
it("cancels the backend request when a running comparison is closed", async () => {
mocks.compare.mockReturnValue(new Promise(() => {}));
const view = render(
<ChangesBetweenDialog from={{ rev: "main", label: "main" }} to={null} scope={scope} onClose={() => {}} />,
);
await waitFor(() => expect(mocks.compare).toHaveBeenCalled());
const request = mocks.compare.mock.calls[0]![3];
view.unmount();
expect(mocks.cancel).toHaveBeenCalledWith(request);
});
});
+17 -9
Переглянути файл
@@ -5,7 +5,13 @@ import { registerActions, type Action } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { activeEditor, editorsFor } from "@/editor/api";
import { Menu, type MenuItem } from "@/ui";
import { editorService, maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions";
import {
editorService,
maybeEditorService,
notifications,
projectViewSelection,
toolWindowService,
} from "@/workbench/contributions";
import { annotationsShown, toggleAnnotations } from "./blame";
import { showBranchesPopup } from "./BranchWidget";
import { checkoutRevision, newBranch, newBranchInRepos } from "./branchOps";
@@ -29,7 +35,7 @@ import { openLogTab, revealCommit, selectGitTab, useGitLog } from "./logStore";
import { isModalCommit, openCommit, pushAfterCommit } from "./commitUi";
import { allRepos, runRemote } from "./ops";
import { showPushDialog, showUpdateDialog, type PushTarget } from "./remoteUi";
import { chooseRevision } from "./RevisionChooser";
import { comparePathsWithRevision, showDirectoryWorkingDiff } from "./comparePaths";
import { showCloneDialog } from "./clone";
import {
showManageRemotes,
@@ -103,6 +109,7 @@ const OPERATIONS: MenuItem[] = [
{ kind: "action", action: "vcs.showHistory" },
{ kind: "action", action: "vcs.annotate" },
{ kind: "action", action: "vcs.showDiff" },
{ kind: "action", action: "vcs.compareWithBranch" },
{ kind: "separator" },
{ kind: "action", action: "vcs.branches" },
{ kind: "action", action: "vcs.push" },
@@ -311,11 +318,13 @@ export function registerVcsActions(): () => void {
if (ctx.shelfFocus && arg === undefined) return showShelfDiff();
const line =
arg && typeof arg === "object" && "line" in arg ? Number((arg as { line: unknown }).line) : undefined;
const paths = contextPaths(arg).filter((p) => !p.endsWith("/"));
const selection = arg === undefined ? projectViewSelection() : [];
const paths = selection.length ? selection : contextPaths(arg);
if (!paths.length) {
notifications().notify({ group: "vcs", level: "info", title: "Select a changed file to show its diff" });
return;
}
if (await showDirectoryWorkingDiff(paths)) return;
for (const p of paths.slice(0, 5)) await openDiff(workingDiffData(p, line), { preview: paths.length === 1 });
},
},
@@ -326,14 +335,13 @@ export function registerVcsActions(): () => void {
icon: "diff",
when: VCS,
run: async ({ arg }) => {
const path = contextPaths(arg)[0];
if (!path) {
notifications().notify({ group: "vcs", level: "info", title: "Open or select a file to compare" });
const selection = arg === undefined ? projectViewSelection() : [];
const paths = selection.length ? selection : contextPaths(arg);
if (!paths.length) {
notifications().notify({ group: "vcs", level: "info", title: "Open or select a file or folder to compare" });
return;
}
const rev = await chooseRevision("Compare with Branch or Revision");
if (!rev) return;
await openDiff({ path, left: { type: "rev", rev, label: rev }, right: { type: "worktree" } });
await comparePathsWithRevision(paths);
},
},
{
+46
Переглянути файл
@@ -282,3 +282,49 @@
.manageRowSelected:hover {
background: var(--bg-list-selected);
}
.comparison {
display: flex;
height: min(520px, 65vh);
min-height: 180px;
padding: 0 12px 12px;
gap: 10px;
}
.comparisonTree {
flex: 1;
min-width: 0;
border: 1px solid var(--border);
}
.comparisonWithDiff .comparisonTree {
flex: 0 0 30%;
}
.comparisonDiff {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid var(--border);
}
.compareNotice {
padding: 6px 14px;
color: var(--fg-subtle);
overflow-wrap: anywhere;
}
.compareAdded {
color: var(--vcs-added);
font-variant-numeric: tabular-nums;
}
.compareRemoved {
color: var(--vcs-deleted);
font-variant-numeric: tabular-nums;
}
@media (max-width: 760px) {
.comparisonWithDiff {
flex-direction: column;
height: 65vh;
}
.comparisonWithDiff .comparisonTree {
flex: 0 0 30%;
min-height: 90px;
}
}
+53
Переглянути файл
@@ -0,0 +1,53 @@
import { call, commands } from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
import { tx } from "@/platform/i18n";
import { chooseRepositoryRevision } from "./RevisionChooser";
import { openDiff } from "./diffInput";
import { showPathComparison } from "./operations";
import { displayPath } from "./compare";
export async function comparePathsWithRevision(paths: string[], revision?: string) {
const scope = await call(commands.vcsCompareScope(paths));
if (!scope.repositories.length) {
notifications().notify({
group: "vcs",
level: "info",
title: "Selection is outside Git repositories",
body: scope.skippedPaths.map((path) => displayPath(path)).join("\n"),
});
return;
}
const choice = revision
? { rev: revision, repositories: null }
: await chooseRepositoryRevision(tx("Compare with Branch or Revision"), {
repositories: scope.repositories,
tags: true,
});
if (!choice) return;
if (scope.singleFile && !revision) {
await openDiff({
path: paths[0]!,
left: { type: "rev", rev: choice.rev, label: choice.rev },
right: { type: "worktree" },
});
} else {
// A shared branch reviews the selection, including explicit errors for roots lacking it.
const repositories = choice.repositories && choice.repositories.length === 1 ? choice.repositories : null;
showPathComparison(paths, choice.rev, repositories);
}
}
/** Returns false for a single file, whose existing Show Diff behavior is retained. */
export async function showDirectoryWorkingDiff(paths: string[]): Promise<boolean> {
const scope = await call(commands.vcsCompareScope(paths));
if (scope.singleFile) return false;
if (!scope.repositories.length) {
notifications().notify({
group: "vcs",
level: "info",
title: "Selection is outside Git repositories",
body: scope.skippedPaths.map((path) => displayPath(path)).join("\n"),
});
} else showPathComparison(paths, "HEAD", null);
return true;
}
+203 -66
Переглянути файл
@@ -4,20 +4,23 @@ import { commitKey, commitRepo } from "./multiRoot";
* cherry-pick or revert, and the Git Log commit actions (Cherry-Pick, Revert
* Commit, Reset Current Branch to Here, New Tag, Compare with Local).
*/
import { useEffect, useState } from "react";
import type { ChangedFile, Commit, ResetMode } from "@/bindings";
import { useEffect, useMemo, useState } from "react";
import type { ChangedFile, Commit, CompareResult, ResetMode, WorkingTreeChange } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, commands } from "@/platform/ipc";
import { Checkbox, Dialog, Icon, Input, List, Spinner, fileIconName, T } from "@/ui";
import { call, commands, nextRequestId } from "@/platform/ipc";
import { Checkbox, Dialog, Icon, Input, Tree, Spinner, fileIconName, T } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { openDiff, type DiffSide } from "./diffInput";
import { diffInput, openDiff, type DiffData, type DiffSide } from "./diffInput";
import { openDialog, promptText } from "./dialogs";
import { KIND_COLOR, baseName, dirName } from "./model";
import { KIND_COLOR, fileCount } from "./model";
import { runGitOperation, runOp } from "./ops";
import { reloadLog, useGitLog } from "./logStore";
import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoOf, useVcs } from "./store";
import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoName, repoOf, useVcs } from "./store";
import styles from "./common.module.css";
import { DiffEditor } from "./DiffEditor";
import { buildFileTree, dirKeys, type FileTreeNode } from "./fileTree";
import { tx } from "@/platform/i18n";
import { displayPath } from "./compare";
// ---------------------------------------------------------------------------
// Operation in progress
@@ -479,102 +482,236 @@ export function showCompareRevisions(from: string, to: string, fromLabel = from,
));
}
export function showPathComparison(paths: string[], rev: string, repositories: string[] | null) {
openDialog((close) => (
<ChangesBetweenDialog from={{ rev, label: rev }} to={null} scope={{ paths, repositories }} onClose={close} />
));
}
interface Revision {
rev: string;
label: string;
}
/** Files that differ between `from` and `to` (`null`: the working tree). */
function ChangesBetweenDialog({ from, to, onClose }: { from: Revision; to: Revision | null; onClose: () => void }) {
interface ScopedComparison {
paths: string[];
repositories: string[] | null;
}
interface ComparisonFile extends ChangedFile {
root: string;
revision: string;
added?: number;
removed?: number;
}
export function comparisonEmpty(revision: string, repositories: number): string {
return repositories === 1
? tx`No changes against ${revision} in 1 repository`
: tx`No changes against ${revision} in ${repositories} repositories`;
}
/** Shared branch/commit comparison surface, also used for directory selections. */
export function ChangesBetweenDialog({
from,
to,
scope,
onClose,
}: {
from: Revision;
to: Revision | null;
scope?: ScopedComparison;
onClose: () => void;
}) {
const [root] = useState(currentRepoRoot);
const [repo] = useState(currentRepo);
const [files, setFiles] = useState<ChangedFile[] | null>(null);
const [files, setFiles] = useState<ComparisonFile[] | null>(null);
const [result, setResult] = useState<CompareResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [active, setActive] = useState(0);
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const [expanded, setExpanded] = useState<ReadonlySet<string> | null>(null);
const [current, setCurrent] = useState<ComparisonFile | null>(null);
const fromRev = from.rev;
const toRev = to?.rev ?? null;
useEffect(() => {
let alive = true;
call(toRev ? commands.vcsDiffRevisions(repo, fromRev, toRev) : commands.vcsDiffWithWorktree(repo, fromRev))
.then((f) => alive && setFiles(f))
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
let pending = true;
const request = nextRequestId();
const load = async () => {
if (scope) {
const r = await call(commands.vcsComparePaths(scope.paths, fromRev, scope.repositories, request));
if (!alive) return;
setResult(r);
setFiles(
r.repositories.flatMap((r) =>
r.files.map((f: WorkingTreeChange) => ({ ...f, root: r.root, revision: r.revision! })),
),
);
} else {
const f = await call(
toRev ? commands.vcsDiffRevisions(repo, fromRev, toRev) : commands.vcsDiffWithWorktree(repo, fromRev),
);
if (alive) setFiles(f.map((f) => ({ ...f, root: root ?? "", revision: fromRev })));
}
};
void load()
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)))
.finally(() => {
pending = false;
});
return () => {
alive = false;
if (scope && pending) void commands.cancelRequest(request);
};
}, [fromRev, toRev]);
const open = (f: ChangedFile) => {
const repoRoot = root;
}, [fromRev, toRev, repo, root, scope]);
const tree = useMemo(() => {
if (!scope) return buildFileTree(files ?? [], (f) => f.path);
return (result?.repositories ?? []).map((r): FileTreeNode<ComparisonFile> => {
const list = (files ?? []).filter((f) => f.root === r.root);
const prefix = (nodes: FileTreeNode<ComparisonFile>[]): FileTreeNode<ComparisonFile>[] =>
nodes.map((n) => ({ ...n, key: `${r.root}:${n.key}`, children: prefix(n.children) }));
return {
key: r.root,
name: repoName(r.root),
file: null,
count: list.length,
children: prefix(buildFileTree(list, (f) => f.path)),
};
});
}, [files, result, scope]);
const diffFor = (f: ComparisonFile): DiffData => {
const missing = (label: string): DiffSide => ({ type: "text", text: "", label });
void openDiff({
path: `${repoRoot}/${f.path}`,
return {
path: `${f.root}/${f.path}`,
left:
f.kind === "added" || f.kind === "untracked"
? missing("(does not exist)")
: { type: "rev", rev: from.rev, label: from.label, path: f.oldPath ? `${repoRoot}/${f.oldPath}` : undefined },
right: !to
? { type: "worktree" }
: f.kind === "deleted"
: {
type: "rev",
rev: f.revision,
label: from.label,
path: f.oldPath ? `${f.root}/${f.oldPath}` : undefined,
},
right:
f.kind === "deleted"
? missing("(deleted)")
: { type: "rev", rev: to.rev, label: to.label },
});
: !to
? { type: "worktree" }
: { type: "rev", rev: to.rev, label: to.label },
};
};
const current = files?.[active];
const input = current ? diffInput(diffFor(current)) : null;
const title = to
? tx`Changes between ${from.label} and ${to.label}`
: tx`Changes between ${from.label} and Local Working Tree`;
const count = files ? fileCount(files.length) : "";
const successful = result?.repositories.filter((r) => !r.error && !r.missingRevision).length ?? 1;
const allMissing = !!result?.repositories.length && result.repositories.every((r) => r.missingRevision);
const skipped = result?.skippedPaths.map((path) => displayPath(path)) ?? [];
const skippedNames = skipped.slice(0, 5).join(", ");
const skippedLabel = skipped.length > 5 ? tx`${skippedNames} and ${skipped.length - 5} more` : skippedNames;
return (
<Dialog
open
title={
to
? tx`Changes between ${from.label} and ${to.label}`
: tx`Changes between ${from.label} and Local Working Tree`
}
title={files ? `${title} — ${count}` : title}
onClose={onClose}
width={620}
width={input ? 1100 : 680}
buttons={[
{ label: "Show Diff", role: "default", disabled: !current, onClick: () => current && open(current) },
{
label: "Show Diff",
role: "default",
disabled: !current,
onClick: () => {
if (current) {
void openDiff(diffFor(current));
onClose();
}
},
},
{ label: "Close", role: "cancel" },
]}
>
<div className={styles.section}>{files ? `${files.length} file${files.length === 1 ? "" : "s"} differ` : ""}</div>
<div style={{ height: 300, display: "flex", flexDirection: "column", padding: "0 14px 14px" }}>
{error ? (
<div className={styles.error}>{error}</div>
) : !files ? (
<div className={styles.empty}>
<Spinner size={14} />
</div>
) : (
<List
items={files}
getKey={(f) => f.path}
activeIndex={Math.min(active, Math.max(0, files.length - 1))}
onActiveChange={setActive}
rowHeight={22}
aria-label="Changed files"
className={styles.boxList}
empty={
<span>
<T>No differences</T>
</span>
}
onActivate={(f, _i, e) => {
if (e.type === "click" && (e as unknown as MouseEvent).detail < 2) return;
open(f);
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{result?.skippedPaths.length ? (
<div className={styles.compareNotice}>{tx`Skipped content outside Git repositories: ${skippedLabel}`}</div>
) : null}
{allMissing ? (
<div className={styles.empty} role="status">
{tx`No repository in the selection has revision ${from.label}`}
</div>
) : (
result?.repositories
.filter((r) => r.error || r.missingRevision)
.map((r) => (
<div key={r.root} className={styles.compareNotice} role={r.missingRevision ? "status" : "alert"}>
{r.missingRevision
? tx`${repoName(r.root)} has no revision ${from.label}`
: tx`Cannot compare ${repoName(r.root)} against ${from.label}: ${r.error}`}
</div>
))
)}
{!files && !error ? (
<div className={styles.empty}>
<Spinner size={14} />
</div>
) : null}
{files && !files.length && successful > 0 ? (
<div className={styles.empty} role="status">
{comparisonEmpty(from.label, successful)}
</div>
) : null}
{files && files.length > 0 ? (
<div className={`${styles.comparison} ${input ? styles.comparisonWithDiff : ""}`}>
<Tree<FileTreeNode<ComparisonFile>>
roots={tree}
getKey={(n) => n.key}
getLabel={(n) => n.name}
getChildren={(n) => (n.file ? undefined : n.children)}
isExpandable={(n) => !n.file}
expanded={expanded ?? new Set(dirKeys(tree))}
onExpandedChange={setExpanded}
selected={selected}
onSelectionChange={(sel, lead) => {
setSelected(sel);
if (lead?.file) setCurrent(lead.file);
}}
renderItem={(f) => (
<span className={styles.fileRow} style={{ flex: 1, padding: 0 }}>
<Icon name={fileIconName(baseName(f.path))} size={14} />
onActivate={(n) => n.file && setCurrent(n.file)}
aria-label="Changed files"
className={styles.comparisonTree}
renderNode={(n) => (
<span className={styles.fileRow} style={{ flex: 1, padding: 0 }} title={n.file ? n.file.path : n.name}>
<Icon name={n.file ? fileIconName(n.name) : "folder"} size={14} />
<span
className={styles.fileName}
style={{ color: KIND_COLOR[f.kind === "untracked" ? "added" : f.kind] }}
style={
n.file ? { color: KIND_COLOR[n.file.kind === "untracked" ? "added" : n.file.kind] } : undefined
}
>
{baseName(f.path)}
{n.name}
</span>
<span className={styles.fileDir}>{dirName(f.path)}</span>
{n.file ? (
<>
<span className={styles.fileDir}>{tx(n.file.kind)}</span>
{n.file.added !== undefined && <span className={styles.compareAdded}>+{n.file.added}</span>}
{n.file.removed !== undefined && <span className={styles.compareRemoved}>−{n.file.removed}</span>}
</>
) : (
<span className={styles.fileDir}>{n.count}</span>
)}
</span>
)}
/>
)}
</div>
{input && (
<div className={styles.comparisonDiff}>
<DiffEditor key={input.key} input={input} />
</div>
)}
</div>
) : null}
</Dialog>
);
}
+11 -1
Переглянути файл
@@ -5348,5 +5348,15 @@
"No debug output available": "暂无可用的调试输出",
"The attached process may not provide standard output to the debugger.": "附加的进程可能不会向调试器提供标准输出。",
"Show in Debug Tool Window": "在调试工具窗口中显示",
"Suspended": "已暂停"
"Suspended": "已暂停",
"Cannot compare {root} against {label}: {error}": "无法将 {root} 与 {label} 比较:{error}",
"Compare with Branch or Revision": "与分支或修订比较",
"No changes against {revision} in 1 repository": "在 1 个仓库中与 {revision} 相比没有更改",
"No changes against {revision} in {repositories} repositories": "在 {repositories} 个仓库中与 {revision} 相比没有更改",
"Open or select a file or folder to compare": "打开或选择要比较的文件或文件夹",
"Selection is outside Git repositories": "所选内容不在 Git 仓库中",
"Skipped content outside Git repositories: {skippedLabel}": "已跳过 Git 仓库之外的内容:{skippedLabel}",
"in {length} repositories": "在 {length} 个仓库中",
"No repository in the selection has revision {label}": "所选内容中的仓库均无版本 {label}",
"{repoName} has no revision {label}": "{repoName} 没有版本 {label}"
}