Compare commits

...
Author SHA1 Message Date
arookieofc 15f618f089 feat(vcs): show worktree diff in the Compare tool window with Jump to Source and Swap Sides
Branch and tag menus' Show Diff with Working Tree opened a dialog (or a
separate comparison window under Tauri). It now shows the worktree diff in
a sidebar Compare tool window instead, with Jump to Source opening the
selected file in the editor and Swap Sides exchanging the two diff sides.

Closes #2442
2026-11-10 14:36:15 +08:00
5 changed files with 293 additions and 26 deletions

No files matched your search

@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
const mocks = vi.hoisted(() => ({
worktree: vi.fn(),
openDiff: vi.fn(),
openFile: vi.fn(),
}));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
commands: { ...real.commands, vcsDiffWithWorktree: mocks.worktree },
};
});
vi.mock("../diffInput", async (original) => {
const real = await original<typeof import("../diffInput")>();
return { ...real, openVcsDiff: mocks.openDiff };
});
vi.mock("../DiffEditor", () => ({
DiffEditor: ({ input }: { input: unknown }) => <div data-testid="diff">{JSON.stringify(input)}</div>,
}));
vi.mock("@/workbench/contributions", async (original) => {
const real = await original<typeof import("@/workbench/contributions")>();
return { ...real, maybeEditorService: () => ({ openFile: mocks.openFile }) };
});
import { useProject } from "@/platform/project";
import { comparisonDiffData, type ComparisonFile } from "../operations";
import { CompareToolWindow, useCompareTool } from "../compareTool";
import { useVcs } from "../store";
const modified: ComparisonFile = {
path: "src/app.ts",
kind: "modified",
oldPath: null,
root: "/workspace/client",
revision: "aaa",
added: 3,
removed: 1,
};
const from = { rev: "main", label: "main" };
describe("comparisonDiffData", () => {
it("puts the revision on the left and the working tree on the right", () => {
const data = comparisonDiffData(modified, from, null, false);
expect(data.path).toBe("/workspace/client/src/app.ts");
expect(data.left).toEqual({ type: "rev", rev: "aaa", label: "main", path: undefined });
expect(data.right).toEqual({ type: "worktree" });
});
it("swaps both sides, for worktree and revision diffs alike", () => {
const worktree = comparisonDiffData(modified, from, null, true);
expect(worktree.left).toEqual({ type: "worktree" });
expect(worktree.right).toEqual({ type: "rev", rev: "aaa", label: "main", path: undefined });
const revisions = comparisonDiffData(modified, from, { rev: "feature", label: "feature" }, true);
expect(revisions.left).toEqual({ type: "rev", rev: "feature", label: "feature" });
expect(revisions.right).toEqual({ type: "rev", rev: "aaa", label: "main", path: undefined });
});
it("keeps the one-sided markers on their file after a swap", () => {
const added: ComparisonFile = { ...modified, kind: "added", path: "src/new.ts" };
expect(comparisonDiffData(added, from, null, false).left).toEqual({
type: "text",
text: "",
label: "(does not exist)",
});
expect(comparisonDiffData(added, from, null, true).right).toEqual({
type: "text",
text: "",
label: "(does not exist)",
});
const deleted: ComparisonFile = { ...modified, kind: "deleted", path: "src/old.ts" };
expect(comparisonDiffData(deleted, from, null, false).right).toEqual({
type: "text",
text: "",
label: "(deleted)",
});
expect(comparisonDiffData(deleted, from, null, true).left).toEqual({
type: "text",
text: "",
label: "(deleted)",
});
});
});
describe("CompareToolWindow", () => {
beforeEach(() => {
vi.clearAllMocks();
useProject.setState({ project: { root: "/workspace", name: "workspace" } as never });
useVcs.setState({ available: true, root: "/workspace", roots: ["/workspace/client"], repo: "/workspace/client" });
mocks.worktree.mockResolvedValue({
status: "ok",
data: [
{ ...modified, added: 3, removed: 1 },
{ path: "src/new.ts", kind: "added", oldPath: null, root: "/workspace/client", revision: "aaa" },
{ path: "src/old.ts", kind: "deleted", oldPath: null, root: "/workspace/client", revision: "aaa" },
],
});
useCompareTool.getState().show(from, null);
});
afterEach(() => {
cleanup();
useCompareTool.getState().close();
useProject.setState({ project: null });
});
it("lists the worktree diff and jumps to the selected source file", async () => {
render(<CompareToolWindow />);
const jump = screen.getByRole("button", { name: "Jump to Source" });
expect(jump).toBeDisabled();
fireEvent.mouseDown(await screen.findByText("app.ts"), { button: 0 });
expect(jump).toBeEnabled();
fireEvent.click(jump);
expect(mocks.openFile).toHaveBeenCalledWith("/workspace/client/src/app.ts", { focus: true });
});
it("has no Jump to Source for a file deleted from the working tree", async () => {
render(<CompareToolWindow />);
fireEvent.mouseDown(await screen.findByText("old.ts"), { button: 0 });
expect(screen.getByRole("button", { name: "Jump to Source" })).toBeDisabled();
});
it("swaps the sides of the shown diff", async () => {
mocks.openDiff.mockResolvedValue(undefined);
render(<CompareToolWindow />);
fireEvent.mouseDown(await screen.findByText("app.ts"), { button: 0 });
fireEvent.click(await screen.findByRole("button", { name: "Show Diff" }));
expect(mocks.openDiff).toHaveBeenLastCalledWith(expect.objectContaining({ right: { type: "worktree" } }));
fireEvent.click(screen.getByRole("button", { name: "Swap Sides" }));
fireEvent.click(screen.getByRole("button", { name: "Show Diff" }));
expect(mocks.openDiff).toHaveBeenLastCalledWith(
expect.objectContaining({
left: { type: "worktree" },
right: expect.objectContaining({ type: "rev", label: "main" }),
}),
);
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* The Compare tool window: `Show Diff with Working Tree` shows the worktree diff
* in the sidebar instead of a dialog, with Jump to Source and Swap Sides (#2442).
*/
import { create } from "zustand";
import { Button } from "@/ui";
import { maybeEditorService, notifications, registerToolWindow } from "@/workbench/contributions";
import { showToolWindow } from "@/workbench/toolwindows/store";
import { openVcsDiff, type DiffData } from "./diffInput";
import { ChangesBetweenDialog, type ComparisonFile, type Revision } from "./operations";
import { repositoryPath } from "./repositoryPaths";
import styles from "./common.module.css";
interface CompareToolState {
target: { from: Revision; to: Revision | null } | null;
swapped: boolean;
current: ComparisonFile | null;
show: (from: Revision, to: Revision | null) => void;
close: () => void;
toggleSwapped: () => void;
select: (file: ComparisonFile | null) => void;
}
export const useCompareTool = create<CompareToolState>()((set) => ({
target: null,
swapped: false,
current: null,
show: (from, to) => set({ target: { from, to }, swapped: false, current: null }),
close: () => set({ target: null, swapped: false, current: null }),
toggleSwapped: () => set((s) => ({ swapped: !s.swapped })),
select: (current) => set({ current }),
}));
let disposeCompareWindow: (() => void) | null = null;
function ensureCompareWindow() {
disposeCompareWindow ??= registerToolWindow({
id: "compare",
title: "Compare",
icon: "diff",
anchor: "left",
order: 2,
component: CompareToolWindow,
});
}
function disposeCompareWindowNow() {
disposeCompareWindow?.();
disposeCompareWindow = null;
}
/** Worktree diff of `rev` in the sidebar's Compare tool window (no dialog). */
export function showWorktreeComparison(rev: string, label: string) {
useCompareTool.getState().show({ rev, label }, null);
ensureCompareWindow();
showToolWindow("compare", true);
}
export function CompareToolWindow() {
const target = useCompareTool((s) => s.target);
const swapped = useCompareTool((s) => s.swapped);
const current = useCompareTool((s) => s.current);
const toggleSwapped = useCompareTool((s) => s.toggleSwapped);
const select = useCompareTool((s) => s.select);
const close = useCompareTool((s) => s.close);
if (!target) return null;
const jumpToSource = () => {
if (!current || current.kind === "deleted") return;
void maybeEditorService()?.openFile(repositoryPath(current.root, current.path), { focus: true });
};
const showDiff = (data: DiffData) => {
void openVcsDiff(data).catch((error) =>
notifications().notify({ level: "error", title: "Could not show diff", body: String(error) }),
);
};
const handleClose = () => {
close();
disposeCompareWindowNow();
};
return (
<div className={styles.comparisonWindow}>
<div className={styles.compareToolbar}>
<Button size="small" onClick={jumpToSource} disabled={!current || current.kind === "deleted"}>
Jump to Source
</Button>
<Button size="small" variant={swapped ? "primary" : "secondary"} onClick={toggleSwapped}>
Swap Sides
</Button>
</div>
<div className={styles.comparisonWindowBody}>
<ChangesBetweenDialog
key={target.from.rev}
from={target.from}
to={target.to}
native
swapped={swapped}
onSelect={select}
onShowDiff={showDiff}
onClose={handleClose}
/>
</div>
</div>
);
}
+47 -26
View File
@@ -22,6 +22,7 @@ import { reloadLog, useGitLog } from "./logStore";
import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoName, repoOf, useVcs } from "./store";
import styles from "./common.module.css";
import { ComparisonTree } from "./ComparisonTree";
import { showWorktreeComparison } from "./compareTool";
import { buildFileTree, type FileTreeNode } from "./fileTree";
import { tx } from "@/platform/i18n";
import { displayPath } from "./compareData";
@@ -507,9 +508,9 @@ export function showCompareWithLocal(commit: Commit) {
showDiffWithWorkingTree(commit.id, short(commit.id));
}
/** Files that differ between `rev` (a commit or branch) and the working tree. */
/** Files that differ between `rev` (a commit or branch) and the working tree, in the Compare tool window. */
export function showDiffWithWorkingTree(rev: string, label = rev) {
showComparison({ rev, label }, null);
showWorktreeComparison(rev, label);
}
/** IDEA's Compare Versions of two Git Log commits: files that differ between them, older first. */
@@ -537,7 +538,7 @@ function showComparison(from: Revision, to: Revision | null, scope?: ScopedCompa
);
}
interface Revision {
export interface Revision {
rev: string;
label: string;
}
@@ -547,13 +548,43 @@ interface ScopedComparison {
repositories: string[] | null;
}
interface ComparisonFile extends ChangedFile {
export interface ComparisonFile extends ChangedFile {
root: string;
revision: string;
added?: number;
removed?: number;
}
/**
* Diff content of one comparison file. `swapped` exchanges the two sides
* (the Compare tool window's Swap Sides), for worktree and revision diffs alike.
*/
export function comparisonDiffData(
file: ComparisonFile,
from: Revision,
to: Revision | null,
swapped: boolean,
): DiffData {
const missing = (label: string): DiffSide => ({ type: "text", text: "", label });
const path = repositoryPath(file.root, file.path);
const left: DiffSide =
file.kind === "added" || file.kind === "untracked"
? missing("(does not exist)")
: {
type: "rev",
rev: file.revision,
label: from.label,
path: file.oldPath ? repositoryPath(file.root, file.oldPath) : undefined,
};
const right: DiffSide =
file.kind === "deleted"
? missing("(deleted)")
: !to
? { type: "worktree" }
: { type: "rev", rev: to.rev, label: to.label };
return swapped ? { path, left: right, right: left } : { path, left, right };
}
export function comparisonEmpty(revision: string, repositories: number): string {
return repositories === 1
? tx`No changes against ${revision} in 1 repository`
@@ -569,6 +600,8 @@ export function ChangesBetweenDialog({
native = false,
onCaption,
onShowDiff,
swapped = false,
onSelect,
}: {
from: Revision;
to: Revision | null;
@@ -578,13 +611,21 @@ export function ChangesBetweenDialog({
native?: boolean;
onCaption?: (caption: string) => void;
onShowDiff?: (data: DiffData) => void;
/** Exchange the two sides (the Compare tool window's Swap Sides). */
swapped?: boolean;
/** Reports the selected file (the Compare tool window's Jump to Source / Swap Sides). */
onSelect?: (file: ComparisonFile | null) => void;
}) {
const [root] = useState(currentRepoRoot);
const [repo] = useState(currentRepo);
const [files, setFiles] = useState<ComparisonFile[] | null>(null);
const [result, setResult] = useState<CompareResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [current, setCurrent] = useState<ComparisonFile | null>(null);
const [current, setCurrentState] = useState<ComparisonFile | null>(null);
const setCurrent = (file: ComparisonFile | null) => {
setCurrentState(file);
onSelect?.(file);
};
const fromRev = from.rev;
const toRev = to?.rev ?? null;
useEffect(() => {
@@ -633,27 +674,7 @@ export function ChangesBetweenDialog({
};
});
}, [files, result, scope]);
const diffFor = (f: ComparisonFile): DiffData => {
const missing = (label: string): DiffSide => ({ type: "text", text: "", label });
return {
path: repositoryPath(f.root, f.path),
left:
f.kind === "added" || f.kind === "untracked"
? missing("(does not exist)")
: {
type: "rev",
rev: f.revision,
label: from.label,
path: f.oldPath ? repositoryPath(f.root, f.oldPath) : undefined,
},
right:
f.kind === "deleted"
? missing("(deleted)")
: !to
? { type: "worktree" }
: { type: "rev", rev: to.rev, label: to.label },
};
};
const diffFor = (f: ComparisonFile): DiffData => comparisonDiffData(f, from, to, swapped);
const input = current ? diffInput(diffFor(current)) : null;
const title = to
? tx`Changes between ${from.label} and ${to.label}`
+2
View File
@@ -8,7 +8,9 @@
"Astro Language Server needs Node.js. Install it or select its executable in Settings › Languages & Frameworks › Node.js.": "Astro Language Server needs Node.js. Install it or select its executable in Settings › Languages & Frameworks › Node.js.",
"Astro Language Server needs a TypeScript language-service library. Install typescript@6 in the project or select a TypeScript lib directory in Settings.": "Astro Language Server needs a TypeScript language-service library. Install typescript@6 in the project or select a TypeScript lib directory in Settings.",
"Cannot show current revision": "Cannot show current revision",
"Compare": "Compare",
"Compare with Revision…": "Compare with Revision…",
"Swap Sides": "Swap Sides",
"Show Current Revision": "Show Current Revision",
"Search file revisions": "Search file revisions",
"File revisions": "File revisions",
+2
View File
@@ -674,6 +674,7 @@
"Commit…": "提交…",
"Compact mode": "紧凑模式",
"Companion object": "伴生对象",
"Compare": "对比",
"Compare Files": "比较文件",
"Compare Texts": "比较文本",
"Compare With": "比较对象",
@@ -2514,6 +2515,7 @@
"Surround With…": "包围方式…",
"Surround selection on typing quote or brace": "输入引号或括号时包围所选内容",
"Suspend on": "挂起时机",
"Swap Sides": "交换两侧",
"Switch to Auto-Commit": "切换到自动提交",
"Switcher": "切换器",
"Switcher (Backward)": "切换器(向后)",