дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
0a71f6be88
коміт
9e35cfc064
20 files changed
+1404
-121
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача